diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index fd06d1c6849..4bd3503103a 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -1,10 +1,8 @@ -import { existsSync, readFileSync, unlinkSync } from 'node:fs' - import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import { getUiState, resetUiState } from '../app/uiStore.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' describe('createSlashHandler', () => { beforeEach(() => { @@ -290,57 +288,63 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet') }) - it('/save writes the current TUI transcript without using the slash worker', () => { - vi.useFakeTimers() - vi.setSystemTime(new Date(2026, 3, 25, 15, 4, 5)) - const filename = 'hermes_conversation_20260425_150405.json' + it('/save forwards to session.save RPC and reports the returned file', async () => { + patchUiState({ sid: 'sid-abc' }) - try { - if (existsSync(filename)) { - unlinkSync(filename) + const rpc = vi.fn(() => Promise.resolve({ file: '/tmp/hermes_conversation_test.json' })) + + const ctx = buildCtx({ + gateway: { ...buildGateway(), rpc }, + local: { + ...buildLocal(), + getHistoryItems: vi.fn(() => [ + { role: 'system', text: 'intro' }, + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'hi there' } + ]) } - - const ctx = buildCtx({ - local: { - ...buildLocal(), - getHistoryItems: vi.fn(() => [ - { role: 'system', text: 'intro' }, - { role: 'user', text: 'hello' }, - { role: 'assistant', text: 'hi there', tools: ['read_file'] }, - { role: 'tool', text: 'tool output' } - ]) - } - }) - - createSlashHandler(ctx)('/save') - - expect(ctx.gateway.gw.request).not.toHaveBeenCalled() - expect(ctx.transcript.sys).toHaveBeenCalledWith(`conversation saved to: ${filename}`) - - const saved = JSON.parse(readFileSync(filename, 'utf8')) - - expect(saved.messages).toEqual([ - { role: 'user', text: 'hello' }, - { role: 'assistant', text: 'hi there', tools: ['read_file'] }, - { role: 'tool', text: 'tool output' } - ]) - } finally { - if (existsSync(filename)) { - unlinkSync(filename) - } - - vi.useRealTimers() - } - }) - - it('/save reports empty state without touching the slash worker', () => { - const ctx = buildCtx() + }) createSlashHandler(ctx)('/save') expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(rpc).toHaveBeenCalledWith('session.save', { session_id: 'sid-abc' }) + + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith( + 'conversation saved to: /tmp/hermes_conversation_test.json' + ) + }) + }) + + it('/save reports empty state without calling the RPC or slash worker', () => { + const rpc = vi.fn(() => Promise.resolve({})) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + createSlashHandler(ctx)('/save') + + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(rpc).not.toHaveBeenCalled() expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet') }) + + it('/save without an active session tells the user instead of hitting the RPC', () => { + // sid stays null (default) but there IS visible conversation + const rpc = vi.fn(() => Promise.resolve({})) + + const ctx = buildCtx({ + gateway: { ...buildGateway(), rpc }, + local: { + ...buildLocal(), + getHistoryItems: vi.fn(() => [{ role: 'user', text: 'hello' }]) + } + }) + + createSlashHandler(ctx)('/save') + + expect(rpc).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to save') + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 81865959c07..6d927fedccc 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,5 +1,3 @@ -import { writeFileSync } from 'node:fs' - import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' @@ -7,6 +5,7 @@ import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from import type { ConfigGetValueResponse, ConfigSetResponse, + SessionSaveResponse, SessionSteerResponse, SessionUndoResponse } from '../../../gatewayTypes.js' @@ -48,24 +47,6 @@ const DETAILS_USAGE = const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expanded|reset]' -const pad2 = (n: number): string => String(n).padStart(2, '0') - -const saveTimestamp = (d = new Date()): string => - `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2( - d.getMinutes() - )}${pad2(d.getSeconds())}` - -const serializableTranscript = (items: Msg[]) => - items - .filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool') - .filter(m => m.text.trim() || m.thinking?.trim() || m.tools?.length) - .map(m => ({ - role: m.role, - text: m.text, - ...(m.thinking ? { thinking: m.thinking } : {}), - ...(m.tools?.length ? { tools: m.tools } : {}) - })) - export const coreCommands: SlashCommand[] = [ { help: 'list commands + hotkeys', @@ -375,33 +356,32 @@ export const coreCommands: SlashCommand[] = [ help: 'save the current transcript to JSON', name: 'save', run: (_arg, ctx) => { - const messages = serializableTranscript(ctx.local.getHistoryItems()) + const hasConversation = ctx.local + .getHistoryItems() + .some(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool') - if (!messages.length) { + if (!hasConversation) { return ctx.transcript.sys('no conversation yet') } - const filename = `hermes_conversation_${saveTimestamp()}.json` - - try { - writeFileSync( - filename, - `${JSON.stringify( - { - model: ctx.ui.info?.model ?? null, - saved_at: new Date().toISOString(), - session_id: ctx.sid, - messages - }, - null, - 2 - )}\n`, - 'utf8' - ) - ctx.transcript.sys(`conversation saved to: ${filename}`) - } catch (error) { - ctx.transcript.sys(`failed to save: ${String(error)}`) + if (!ctx.sid) { + return ctx.transcript.sys('no active session — nothing to save') } + + ctx.gateway + .rpc('session.save', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const file = r?.file + + if (file) { + ctx.transcript.sys(`conversation saved to: ${file}`) + } else { + ctx.transcript.sys('failed to save') + } + }) + ) + .catch(ctx.guardedErr) } }, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 50ef505e619..e64d113c22a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -119,6 +119,10 @@ export interface SessionListResponse { sessions?: SessionListItem[] } +export interface SessionSaveResponse { + file?: string +} + export interface SessionUndoResponse { removed?: number }