mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
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:
@@ -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 => ({
|
||||
|
||||
@@ -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 <section> [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<SessionSaveResponse>('session.save', { session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<SessionSaveResponse>(r => {
|
||||
const file = r?.file
|
||||
|
||||
if (file) {
|
||||
ctx.transcript.sys(`conversation saved to: ${file}`)
|
||||
} else {
|
||||
ctx.transcript.sys('failed to save')
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -119,6 +119,10 @@ export interface SessionListResponse {
|
||||
sessions?: SessionListItem[]
|
||||
}
|
||||
|
||||
export interface SessionSaveResponse {
|
||||
file?: string
|
||||
}
|
||||
|
||||
export interface SessionUndoResponse {
|
||||
removed?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user