fix(tui): route /save through session.save JSON-RPC

The cherry-picked approach serialized the UI-shaped transcript on the Node
side, producing a third JSON format alongside cli.py save_conversation and
tui_gateway session.save. Simpler to call the existing session.save method,
which already writes the canonical agent history (raw OpenAI messages +
model) to an absolute-path file.

- /save still short-circuits before the slash worker
- Empty transcript -> 'no conversation yet'
- No active session -> 'no active session - nothing to save'
- Otherwise: rpc('session.save', {session_id}) and echo back the file path
- Tests updated to assert RPC contract; new test covers the no-sid case
This commit is contained in:
Teknium 2026-04-25 18:04:09 -07:00 committed by Teknium
parent 1b8ca9254f
commit 2536a36f6f
3 changed files with 76 additions and 88 deletions

View file

@ -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> = {}): Ctx => ({