fix(desktop): salvage /compress cluster — session.compress RPC + dedicated RPC routing (#68229)

* fix(desktop): route /compress through session.compress RPC with transcript replacement

Salvages #44462, #53755, and #68218 into a single canonical fix for the
desktop /compress cluster.

The desktop routed /compress through slash.exec, which sends it to the
_SlashWorker subprocess. Compressing a large session outlives both the
desktop's 30s WS timeout and the worker's 45s pipe timeout — the client
gives up, runExec's blanket catch swallows the error, and command.dispatch
surfaces a misleading "not a quick/plugin/skill command: compress" (#44456).
Even when compression succeeded via the _mirror_slash_side_effects path,
the desktop never received the post-compress message list, so summarized
bubbles stayed on screen forever — /compress looked like a no-op.

This change routes /compress to the dedicated session.compress RPC (the TUI's
path), combining the best of all three PRs:

- 120s client timeout matching the TUI's HERMES_TUI_RPC_TIMEOUT_MS (#44462)
- Transcript replacement from the response `messages` via toChatMessages,
  the same converter session.resume uses (#68218, teknium1 review on #44462)
- Session-isolation guard: updateSessionState only publishes for the active
  runtime, so a late result after a session switch can't clobber the
  foreground transcript (#53755, teknium1 review on #53755)
- Coalescing: dedup concurrent compress requests per session (#53755)
- Progress toast ("compressing context...") outside the transcript (#53755)
- Error unmasking in runExec: when slash.exec fails and command.dispatch only
  adds "not a quick/plugin/skill command" routing noise, surface the original
  worker error instead (#44462)
- /compact alias + focus_topic forwarding

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>

* feat(desktop): route slash commands with dedicated RPCs to those RPCs

Salvages #63513 — introduces a new `rpc` kind on DesktopCommandSurface so
commands with a first-class gateway @method handler bypass slash.exec /
command.dispatch entirely, and a `renderRpcResult` utility that shapes
each RPC's structured reply into readable transcript text.

Migrates 6 commands from exec() to rpc(...):
  /agents → agents.list
  /save   → session.save
  /status → session.status
  /steer  → session.steer
  /stop   → process.stop
  /usage  → session.usage

/compress stays as action('compress') — it needs transcript replacement
from the response `messages`, which the generic rpc path can't do (per
teknium1 review on #44462/#63513).

Also includes the json-rpc-gateway timeout message improvement: the error
now includes the configured timeout duration ("request timed out after 120s:
session.compress") so a user can tell whether the default 30s fired or a
per-call override.

Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>

* fix(desktop): preserve provider choice during config initialization

* fix(desktop): preserve slash command and host compression semantics

Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec.
Propagate the full compression timeout through compute-host control, return structured
host compression outcomes with metadata, and retain successful compression feedback
in the desktop transcript.

Add regressions for timeout forwarding, host aborts and metadata sync, structured host
control responses, command routing parity, and numeric stop counts.

* fix(desktop): harden compression state handling

Preserve the invoking stored-session binding for delayed compression results,
normalize replacement histories, and serialize provider selection. Stabilize
gateway platform tests and guard the desktop Git facade during renderer teardown.

---------

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>
Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>
This commit is contained in:
ethernet 2026-07-22 16:40:06 -04:00 committed by GitHub
parent 1bdd478efa
commit 41e00dcf87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1612 additions and 37 deletions

View file

@ -5,7 +5,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
import { $notifications, clearNotifications } from '@/store/notifications'
import {
$busy,
$connection,
$currentUsage,
$messages,
$sessions,
$turnStartedAt,
setCurrentUsage,
setMessages,
setSessions
} from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import type { SubmitTextOptions } from './utils'
@ -64,6 +75,7 @@ interface HarnessHandle {
redirectPrompt: (text: string) => Promise<boolean>
/** @deprecated Use `redirectPrompt`. */
steerPrompt: (text: string) => Promise<boolean>
submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise<boolean>
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
}
@ -100,7 +112,7 @@ function Harness({
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
selectedStoredSessionIdRef?: MutableRefObject<string | null>
@ -166,6 +178,7 @@ function Harness({
act(async () => actions.redirectPrompt(...args)) as Promise<boolean>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitTextRaw: actions.submitText,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
@ -283,6 +296,511 @@ describe('usePromptActions /title', () => {
})
})
// Helper: extract rendered text parts from captured updateSessionState seeds.
function renderedSeedTexts(seeds: Record<string, unknown>[]): string[] {
return seeds.flatMap(state => {
const messages = Array.isArray(state.messages)
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
: []
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
})
}
describe('usePromptActions /compress', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
clearNotifications()
setCurrentUsage({ calls: 0, input: 0, output: 0, total: 0 })
setMessages([])
vi.restoreAllMocks()
})
it('routes through session.compress (not slash.exec) with a 120s timeout and renders the summary', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
if (method === 'session.compress') {
return {
removed: 8,
summary: {
headline: 'Compressed: 234 → 226 messages',
noop: false,
token_line: 'Approx request size: ~285,727 → ~198,104 tokens'
}
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
// The dedicated RPC is the TUI's path and has no slash-worker pipe
// timeout — and the call carries an LLM-sized client timeout instead of
// the 30s WS default, so a large session's compression can finish.
expect(requestGateway).toHaveBeenCalledWith(
'session.compress',
expect.objectContaining({ session_id: RUNTIME_SESSION_ID }),
120_000
)
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
})
it('replaces the transcript from the response messages', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
if (method === 'session.compress') {
return {
removed: 2,
summary: { headline: 'Compressed: 4 → 2 messages' },
messages: [
{ role: 'user', content: 'summarized context' },
{ role: 'assistant', content: 'sure, here is the summary' }
]
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
// The transcript was replaced with the post-compress history.
const finalMessages = seeds[seeds.length - 1]?.messages as Array<{ parts?: Array<{ text?: string }> }>
const renderedText = (finalMessages ?? [])
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
.join('\n')
expect(renderedText).toContain('summarized context')
expect(renderedText).toContain('sure, here is the summary')
})
it('uses the compute-host response transcript and success output', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return {
host_ack: { output: 'Compressed 4 → 2 messages' },
messages: [
{ role: 'user', content: 'compute-host summary' },
{ role: 'assistant', content: 'compute-host answer' }
]
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
const computeHostTexts = renderedSeedTexts(seeds)
expect(computeHostTexts).toEqual(expect.arrayContaining(['compute-host summary', 'compute-host answer']))
expect(computeHostTexts.some(text => text.includes('Compressed 4 → 2 messages'))).toBe(true)
expect($notifications.get()).toEqual(
expect.arrayContaining([expect.objectContaining({ message: 'Compressed 4 → 2 messages' })])
)
})
it('renders an aborted compression as an error, not a success', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return {
status: 'aborted',
summary: {
aborted: true,
headline: 'Compression aborted: 6 messages preserved',
note: 'No compression provider is configured.'
}
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await handle!.submitText('/compress')
expect($notifications.get()).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'error', message: expect.stringContaining('Compression aborted') })
])
)
expect($notifications.get()).not.toEqual(
expect.arrayContaining([expect.objectContaining({ kind: 'success', message: expect.stringContaining('Compression aborted') })])
)
})
it('passes a focus topic through as focus_topic', async () => {
const requestGateway = vi.fn(
async (_method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => ({ removed: 0 }) as never
)
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await handle!.submitText('/compress the auth refactor')
expect(requestGateway).toHaveBeenCalledWith(
'session.compress',
expect.objectContaining({ focus_topic: 'the auth refactor' }),
120_000
)
})
it('surfaces the RPC error verbatim (e.g. the busy guard) instead of a routing error', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
throw new Error('session busy — /interrupt the current turn before /compress')
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
const texts = renderedSeedTexts(seeds)
expect(texts.some(text => text.includes('session busy'))).toBe(true)
expect(texts.some(text => text.includes('not a quick/plugin/skill command'))).toBe(false)
})
it('falls back to the slash worker when an older gateway lacks session.compress', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
throw new Error('method not found: session.compress')
}
if (method === 'slash.exec') {
return { output: 'compressed by legacy gateway' } as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'compress' }))
expect(renderedSeedTexts(seeds).some(text => text.includes('compressed by legacy gateway'))).toBe(true)
})
it('does not clobber the foreground transcript when compression resolves after a session switch', async () => {
const RUNTIME_SESSION_B = 'rt-session-b'
const storedSessionIdRef: MutableRefObject<string | null> = { current: 'stored-a' }
const updates: Array<{ sessionId: string; storedSessionId: null | string | undefined }> = []
let resolveCompress: (value: unknown) => void = () => undefined
const compressResult = new Promise(resolve => {
resolveCompress = resolve
})
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}
throw new Error(`unexpected method: ${method}`)
})
setMessages([{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user', timestamp: 0 }])
setCurrentUsage({ calls: 7, input: 70, output: 30, total: 100 })
let handle: HarnessHandle | null = null
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId) => updates.push({ sessionId, storedSessionId })}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={storedSessionIdRef}
/>
)
// Start the request in a synchronous act. The async `submitText` harness
// helper cannot be used here: this test intentionally keeps its promise
// pending while the user switches sessions, which would leave React's
// async act scope open and overlap the wait below.
let submitted: Promise<boolean>
act(() => {
submitted = handle!.submitTextRaw('/compress')
})
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.anything(), 120_000))
// Switch to session B before compression resolves.
activeSessionIdRef.current = RUNTIME_SESSION_B
resolveCompress({
info: { usage: { context_used: 4_000, total: 12_000 } },
messages: [{ content: 'compressed session A transcript', role: 'system' }],
removed: 5
})
await act(async () => {
await submitted
})
// The foreground transcript + usage are unchanged — the late result only
// updated session A's cache, not the active session B's.
expect($messages.get()).toEqual([
{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user', timestamp: 0 }
])
expect($currentUsage.get()).toEqual(expect.objectContaining({ calls: 7, input: 70, output: 30, total: 100 }))
expect(updates).toContainEqual({ sessionId: RUNTIME_SESSION_ID, storedSessionId: 'stored-a' })
})
it('keeps a late compression error bound to its invocation-time stored session', async () => {
const RUNTIME_SESSION_B = 'rt-session-b'
const storedSessionIdRef: MutableRefObject<string | null> = { current: 'stored-a' }
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const updates: Array<{ sessionId: string; storedSessionId: null | string | undefined }> = []
let rejectCompress: (reason?: unknown) => void = () => undefined
const compressResult = new Promise((_, reject) => {
rejectCompress = reject
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId) => updates.push({ sessionId, storedSessionId })}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={storedSessionIdRef}
/>
)
// Keep the RPC pending while the selected stored session changes without
// leaving an async React act scope open (see the foreground-race test).
let submitted: Promise<boolean>
act(() => {
submitted = handle!.submitTextRaw('/compress')
})
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.anything(), 120_000))
activeSessionIdRef.current = RUNTIME_SESSION_B
storedSessionIdRef.current = 'stored-b'
rejectCompress(new Error('compression failed'))
await act(async () => {
await submitted
})
expect(updates).toContainEqual({ sessionId: RUNTIME_SESSION_ID, storedSessionId: 'stored-a' })
})
it('shows a compression progress toast outside the transcript', async () => {
let resolveCompress: (value: unknown) => void = () => undefined
const compressResult = new Promise(resolve => {
resolveCompress = resolve
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const submitted = handle!.submitTextRaw('/compress')
await waitFor(() => expect($notifications.get().some(item => item.message === 'compressing context...')).toBe(true))
resolveCompress({ messages: [{ content: 'compressed transcript', role: 'system' }] })
await submitted
})
})
describe('usePromptActions exec fallback error reporting', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('surfaces the slash.exec failure when command.dispatch only adds "not a quick/plugin/skill command"', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'slash.exec') {
throw new Error('slash worker timed out')
}
if (method === 'command.dispatch') {
throw new Error('not a quick/plugin/skill command: debug')
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
// /debug still goes through exec (no dedicated RPC), so it exercises the
// slash.exec → command.dispatch fallback + error unmasking path.
await handle!.submitText('/debug')
// The dispatch fallback knowing nothing about /debug is routing noise;
// the worker timeout is what actually went wrong (#44456).
const texts = renderedSeedTexts(seeds)
expect(texts.some(text => text.includes('slash worker timed out'))).toBe(true)
expect(texts.some(text => text.includes('not a quick/plugin/skill command'))).toBe(false)
})
it('falls back to slash.exec when an older gateway lacks a dedicated RPC', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.status') {
throw new Error('method not found: session.status')
}
if (method === 'slash.exec') {
return { output: 'session status from slash worker' } as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/status')
expect(requestGateway).toHaveBeenCalledWith('session.status', expect.anything(), undefined)
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'status' }))
expect(renderedSeedTexts(seeds).some(text => text.includes('session status from slash worker'))).toBe(true)
})
it('still reports a real command.dispatch failure for skill/quick commands', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'slash.exec') {
throw new Error('skill command: use command.dispatch for /my-skill')
}
if (method === 'command.dispatch') {
throw new Error('quick command failed with exit code 1')
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/my-skill')
const texts = renderedSeedTexts(seeds)
expect(texts.some(text => text.includes('quick command failed with exit code 1'))).toBe(true)
})
})
describe('usePromptActions slash.exec dispatch payloads', () => {
afterEach(() => {
cleanup()

View file

@ -220,7 +220,7 @@ export function usePromptActions({
const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
(sessionId: string, role: ChatMessage['role'], text: string, storedSessionId?: string | null) => {
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
@ -244,7 +244,7 @@ export function usePromptActions({
}
]
}),
selectedStoredSessionIdRef.current
storedSessionId ?? selectedStoredSessionIdRef.current
)
},
[selectedStoredSessionIdRef, updateSessionState]
@ -478,8 +478,10 @@ export function usePromptActions({
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText
submitPromptText,
updateSessionState
})
const submitText = useCallback(

View file

@ -1,21 +1,23 @@
import { type MutableRefObject, useCallback } from 'react'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { getProfiles } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage } from '@/lib/chat-messages'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
import { parseCommandDispatch, parseSlashCommand, sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
type DesktopActionId,
type DesktopCommandSurface,
type DesktopPickerId,
desktopSlashUnavailableMessage,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import { setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { dismissNotification, notify, notifyError } from '@/store/notifications'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@ -23,22 +25,35 @@ import {
$connection,
$sessions,
$yoloActive,
setCurrentUsage,
setModelPickerOpen,
setSessionPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
import type {
BrowserManageResponse,
ClientSessionState,
SessionCompressResponse,
SessionTitleResponse,
SlashExecResponse
} from '../../../types'
import {
type GatewayRequest,
isSessionIdCandidate,
renderCommandsCatalog,
renderRpcResult,
slashStatusText,
type SubmitTextOptions
} from './utils'
// Manual compression is LLM-bound and routinely outlives the desktop's 30s
// default WS request timeout on large sessions — give it the TUI client's
// 120s RPC budget (HERMES_TUI_RPC_TIMEOUT_MS default) instead.
const SESSION_COMPRESS_TIMEOUT_MS = 120_000
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
arg: string
@ -50,7 +65,12 @@ interface SlashActionCtx {
interface SlashCommandDeps {
activeSessionIdRef: MutableRefObject<string | null>
appendSessionTextMessage: (sessionId: string, role: ChatMessage['role'], text: string) => void
appendSessionTextMessage: (
sessionId: string,
role: ChatMessage['role'],
text: string,
storedSessionId?: string | null
) => void
branchCurrentSession: () => Promise<boolean>
busyRef: MutableRefObject<boolean>
copy: Translations['desktop']
@ -64,8 +84,14 @@ interface SlashCommandDeps {
refreshSessions: () => Promise<void>
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
/** The /slash command dispatcher, extracted from usePromptActions. */
@ -83,10 +109,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText
submitPromptText,
updateSessionState
} = deps
const compressInFlightRef = useRef(new Set<string>())
return useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
const ensureSessionId = async (sessionHint?: string) =>
@ -97,7 +127,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
// build-renderSlashOutput boilerplate every exec-style handler repeats.
const withSlashOutput = async (
ctx: SlashActionCtx
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
): Promise<{ render: (text: string) => void; sessionId: string; storedSessionId: string | null } | null> => {
const sessionId = await ensureSessionId(ctx.sessionHint)
if (!sessionId) {
@ -106,10 +136,19 @@ export function useSlashCommand(deps: SlashCommandDeps) {
return null
}
const render = (text: string) =>
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
// A long-running command can finish after a session switch. Keep its
// output bound to the stored session selected at invocation time.
const storedSessionId = selectedStoredSessionIdRef.current
return { render, sessionId }
const render = (text: string) =>
appendSessionTextMessage(
sessionId,
'system',
ctx.recordInput ? slashStatusText(ctx.command, text) : text,
storedSessionId
)
return { render, sessionId, storedSessionId }
}
// `exec` commands (and unknown skill / quick commands the backend owns)
@ -131,6 +170,8 @@ export function useSlashCommand(deps: SlashCommandDeps) {
return
}
let slashExecError: unknown = null
const handleDispatch = async (
dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>
): Promise<void> => {
@ -206,8 +247,11 @@ export function useSlashCommand(deps: SlashCommandDeps) {
renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body)
return
} catch {
// Fall back to command.dispatch for skill/send/alias directives.
} catch (error) {
// Fall back to command.dispatch for skill/send/alias directives, but
// keep the worker error: a slash.exec worker timeout/crash is the real
// failure, not the "not a quick/plugin/skill command" routing noise.
slashExecError = error
}
try {
@ -223,6 +267,66 @@ export function useSlashCommand(deps: SlashCommandDeps) {
await handleDispatch(dispatch)
} catch (err) {
// "not a quick/plugin/skill command" just means the fallback had
// nothing to add — the slash.exec failure (worker timeout, crash) is
// the real error, so don't bury it under the routing noise.
const dispatchMessage = err instanceof Error ? err.message : String(err)
if (slashExecError && /not a quick\/plugin\/skill command/i.test(dispatchMessage)) {
const original = slashExecError instanceof Error ? slashExecError.message : String(slashExecError)
renderSlashOutput(`error: /${name} failed: ${original}`)
return
}
renderSlashOutput(`error: ${dispatchMessage}`)
}
}
// `rpc` commands have a dedicated gateway handler — skip slash.exec /
// command.dispatch entirely. The response is structured (a JSON RPC
// reply, not an inline text stream) so we render it via the generic
// `renderRpcResult` shaper that knows the field conventions shared by
// session.save / session.status / session.usage / session.steer /
// process.stop / agents.list.
async function runRpc(
surface: Extract<DesktopCommandSurface, { kind: 'rpc' }>,
ctx: SlashActionCtx
): Promise<void> {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
try {
const params = surface.buildParams({
arg: ctx.arg,
command: ctx.command,
name: ctx.name,
sessionId
})
// Forward the surface's declared timeout when present; the default
// requestGateway layer keeps (30s) is too tight for RPCs that do
// real work.
const result = await requestGateway<unknown>(surface.rpc, params, surface.timeoutMs)
const body = renderRpcResult(result, ctx.name)
renderSlashOutput(body || `/${ctx.name}: no output`)
} catch (err) {
// TODO: remove this compatibility fallback once every supported
// managed runtime exposes the dedicated RPC surface. Desktop and its
// gateway update independently; older gateways still support the
// slash-worker route.
if (isMissingRpcMethod(err)) {
await runExec(ctx)
return
}
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
@ -237,6 +341,139 @@ export function useSlashCommand(deps: SlashCommandDeps) {
branch: async () => {
await branchCurrentSession()
},
// /compress (alias /compact) runs the gateway's dedicated
// session.compress RPC — the TUI's path
// (ui-tui/src/app/slash/commands/session.ts). It must NOT go through
// runExec: compressing a large session outlives the slash worker's pipe
// timeout (45s) and the desktop's 30s WS default, and the resulting
// slash.exec error cascaded into command.dispatch's misleading "not a
// quick/plugin/skill command: compress" (#44456).
//
// The RPC returns the post-compress `messages` array (same shape
// session.resume returns), so we replace the transcript from it —
// otherwise the summarized bubbles stay on screen forever (#44462
// review). `updateSessionState` only publishes for the active runtime,
// so a late result after a session switch refreshes its own cache
// without clobbering the foreground transcript (#53755 review).
compress: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId, storedSessionId } = resolved
const focusTopic = ctx.arg.trim()
const noticeId = `session-compress:${sessionId}`
// Coalesce concurrent compress requests for the same session so a
// double-enter doesn't fire two LLM summarise calls.
if (compressInFlightRef.current.has(sessionId)) {
return
}
compressInFlightRef.current.add(sessionId)
notify({
durationMs: 0,
id: noticeId,
kind: 'info',
message: focusTopic ? `compressing context for: ${focusTopic}` : 'compressing context...'
})
try {
const result = await requestGateway<SessionCompressResponse>(
'session.compress',
{
session_id: sessionId,
...(focusTopic ? { focus_topic: focusTopic } : {})
},
SESSION_COMPRESS_TIMEOUT_MS
)
// Replace the transcript with the post-compress history so the
// summarized bubbles actually disappear. `messages` is the same
// shape session.resume returns (_history_to_messages), so
// toChatMessages handles it directly. updateSessionState only
// publishes for the active runtime, guarding against a late result
// clobbering the foreground after a session switch.
if (Array.isArray(result?.messages)) {
updateSessionState(
sessionId,
state => ({ ...state, messages: toChatMessages(result.messages!) }),
storedSessionId
)
}
const usage = { ...result?.usage, ...result?.info?.usage }
if (Object.keys(usage).length && activeSessionIdRef.current === sessionId) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
if (result?.info?.title !== undefined) {
setSessions(prev =>
prev.map(session =>
session.id === sessionId ? { ...session, title: result.info!.title || null } : session
)
)
}
if (result?.summary?.headline) {
const lines = [result.summary.headline, result.summary.token_line, result.summary.note].filter(
(line): line is string => Boolean(line)
)
const aborted = result.status === 'aborted' || result.summary.aborted === true
if (!aborted) {
// Keep a durable record of a successful manual compression in
// the chat after replacing it with the authoritative backend
// transcript. Errors remain transient: appending an error as a
// system message would look like a successful state change.
renderSlashOutput(lines.join('\n'))
}
notify({ durationMs: 5_000, id: noticeId, kind: aborted ? 'error' : 'success', message: lines.join('\n') })
return
}
const hostOutput = result?.host_ack?.output?.trim()
if (hostOutput) {
renderSlashOutput(hostOutput)
notify({ durationMs: 5_000, id: noticeId, kind: 'success', message: hostOutput })
return
}
const removed = result?.removed ?? 0
const message = removed > 0 ? `compressed ${removed} messages` : 'nothing to compress'
renderSlashOutput(message)
notify({
durationMs: 5_000,
id: noticeId,
kind: 'success',
message
})
} catch (err) {
dismissNotification(noticeId)
// Desktop and gateway runtimes update independently. Preserve the
// historical slash-worker path for an older gateway that has not
// shipped session.compress yet; it cannot offer the longer RPC
// timeout, but it must not turn a once-working command into an
// immediate "method not found" error.
if (isMissingRpcMethod(err)) {
await runExec(ctx)
return
}
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
} finally {
compressInFlightRef.current.delete(sessionId)
}
},
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
@ -607,6 +844,9 @@ export function useSlashCommand(deps: SlashCommandDeps) {
case 'action':
return actionHandlers[surface.action](ctx)
case 'rpc':
return runRpc(surface, ctx)
default:
// exec spec, or an unknown skill / quick command the backend owns.
return runExec(ctx)
@ -628,8 +868,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText
submitPromptText,
updateSessionState
]
)
}

View file

@ -12,6 +12,7 @@ import {
isSessionBusyError,
isSessionIdCandidate,
isSessionNotFoundError,
renderRpcResult,
slashStatusText,
visibleUserIndexAtOrdinal,
visibleUserOrdinal
@ -125,3 +126,139 @@ describe('visible user ordinals', () => {
expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1)
})
})
describe('renderRpcResult', () => {
describe('session.compress (summary shape)', () => {
it('renders the summary headline with token line and note', () => {
expect(
renderRpcResult(
{
summary: {
headline: 'Compressed: 280 → 120 messages',
token_line: 'Approx request size: ~126,575 → ~30,000 tokens',
note: 'Removed 8 older turns',
noop: false
}
},
'compress'
)
).toBe(
[
'✓ Compressed: 280 → 120 messages',
' Approx request size: ~126,575 → ~30,000 tokens',
' Removed 8 older turns'
].join('\n')
)
})
it('drops the checkmark when the summary is a noop', () => {
expect(
renderRpcResult(
{ summary: { headline: 'Already compressed', note: 'No new turns since last compress', noop: true } },
'compress'
)
).toBe('Already compressed\n No new turns since last compress')
})
})
describe('session.steer', () => {
it('reports a queued steer with the original text', () => {
expect(renderRpcResult({ status: 'queued', text: 'skip the docs' }, 'steer')).toBe(
'Steered · "skip the docs" queued for next tool call'
)
})
it('reports a rejected steer without echoing user text', () => {
expect(renderRpcResult({ status: 'rejected', text: 'whatever' }, 'steer')).toBe(
'Steer rejected — agent declined input'
)
})
})
describe('process.stop', () => {
it('reports the numeric number of stopped processes', () => {
expect(renderRpcResult({ killed: 2 }, 'stop')).toBe('Stopped 2 background processes.')
})
it('reports nothing-to-stop when the numeric count is zero', () => {
expect(renderRpcResult({ killed: 0 }, 'stop')).toBe('No background processes to stop.')
})
})
describe('session.save', () => {
it('echoes the saved file path', () => {
expect(renderRpcResult({ file: '/home/user/.hermes/sessions/saved/x.json' }, 'save')).toBe(
'Saved transcript to /home/user/.hermes/sessions/saved/x.json'
)
})
})
describe('session.status', () => {
it('passes through the multi-line plain-text output verbatim', () => {
const output = 'Hermes TUI Status\n\nSession ID: s-1\nModel: nous-hermes-3 (unknown)'
expect(renderRpcResult({ output }, 'status')).toBe(output)
})
})
describe('session.usage', () => {
it('formats calls / input / output / total with thousands separators', () => {
expect(renderRpcResult({ calls: 12, input: 1_234_567, output: 89_012, total: 1_323_579 }, 'usage')).toBe(
'Usage: 12 calls · 1,234,567 in / 89,012 out · 1,323,579 total'
)
})
it('appends credits_lines when present', () => {
const body = renderRpcResult(
{
calls: 1,
input: 10,
output: 20,
total: 30,
credits_lines: ['Nous credits: 8,420 remaining', 'Resets: 2026-08-01']
},
'usage'
)
expect(body.split('\n')).toEqual([
'Usage: 1 calls · 10 in / 20 out · 30 total',
'Nous credits: 8,420 remaining',
'Resets: 2026-08-01'
])
})
})
describe('agents.list', () => {
it('reports no running tasks when the array is empty', () => {
expect(renderRpcResult({ processes: [] }, 'agents')).toBe('No background tasks running.')
})
it('formats each process with status, command, and metadata', () => {
expect(
renderRpcResult(
{
processes: [
{ session_id: 's-1', command: 'npm test', status: 'running', uptime: 42 },
{ session_id: 's-2', command: 'vitest', status: 'completed' }
]
},
'agents'
)
).toBe(['• [running] npm test (42s · s-1)', '• [completed] vitest (s-2)'].join('\n'))
})
})
describe('fallback', () => {
it('serialises unknown shapes as JSON so we never lose data', () => {
expect(renderRpcResult({ custom: 'value', nested: { a: 1 } }, 'mystery')).toBe(
'/mystery: {"custom":"value","nested":{"a":1}}'
)
})
it('returns an empty string for null and primitive payloads', () => {
expect(renderRpcResult(null, 'x')).toBe('')
expect(renderRpcResult(undefined, 'x')).toBe('')
expect(renderRpcResult('plain string', 'x')).toBe('')
expect(renderRpcResult(42, 'x')).toBe('')
})
})
})

View file

@ -192,6 +192,138 @@ export function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
/**
* Format the JSON reply from a gateway RPC surfaced as `kind: 'rpc'` in
* desktop-slash-commands.ts. Kept here (instead of slash.ts) so it has no
* React / store dependencies and can be unit-tested in isolation.
*
* The renderer follows the field conventions shared by the gateway handlers
* we route to today:
* - `session.compress`: { summary: { headline, token_line, note, noop } }
* (only used if /compress ever moves to `rpc`; it's an `action` today
* because it needs transcript replacement)
* - `session.status`: { output: "<multi-line plain text>" }
* - `session.save`: { file: "<absolute path>" }
* - `session.usage`: { calls, input, output, total, credits_lines? }
* - `session.steer`: { status: 'queued' | 'rejected', text }
* - `process.stop`: { killed: boolean }
* - `agents.list`: { processes: [{ session_id, command, status, uptime }] }
*
* Any RPC whose response doesn't match these shapes falls through to a
* JSON.stringify dump so we never silently swallow data.
*/
export function renderRpcResult(response: unknown, name: string): string {
if (!response || typeof response !== 'object') {
return ''
}
const r = response as Record<string, unknown>
const summary = r.summary as { headline?: string; token_line?: string; note?: string; noop?: boolean } | undefined
if (summary && typeof summary === 'object' && typeof summary.headline === 'string' && summary.headline) {
const lines: string[] = [`${summary.noop ? '' : '✓ '}${summary.headline}`]
if (summary.token_line) {
lines.push(` ${summary.token_line}`)
}
if (summary.note) {
lines.push(` ${summary.note}`)
}
return lines.join('\n')
}
// session.steer — { status: 'queued' | 'rejected', text }
if (r.status === 'queued' || r.status === 'rejected') {
const text = typeof r.text === 'string' ? r.text : ''
if (r.status === 'queued') {
return text ? `Steered · "${text}" queued for next tool call` : 'Steered next tool call'
}
return 'Steer rejected — agent declined input'
}
// process.stop — { killed: number }
if ('killed' in r && typeof r.killed === 'number') {
return r.killed > 0 ? `Stopped ${r.killed} background process${r.killed === 1 ? '' : 'es'}.` : 'No background processes to stop.'
}
// session.save — { file }
if (typeof r.file === 'string' && r.file) {
return `Saved transcript to ${r.file}`
}
// session.status — { output }
if (typeof r.output === 'string' && r.output) {
return r.output
}
// session.usage — { calls, input, output, total, credits_lines? }
if ('total' in r || 'input' in r || 'output' in r || 'calls' in r) {
const calls = Number(r.calls ?? 0)
const input = Number(r.input ?? 0)
const output = Number(r.output ?? 0)
const total = Number(r.total ?? 0)
const lines: string[] = [
`Usage: ${calls.toLocaleString()} calls · ${input.toLocaleString()} in / ${output.toLocaleString()} out · ${total.toLocaleString()} total`
]
if (Array.isArray(r.credits_lines)) {
for (const credit of r.credits_lines) {
if (typeof credit === 'string' && credit.trim()) {
lines.push(credit.trim())
}
}
}
return lines.join('\n')
}
// agents.list — { processes: [{ session_id, command, status, uptime }] }
if (Array.isArray(r.processes)) {
if (r.processes.length === 0) {
return 'No background tasks running.'
}
return r.processes
.map(p => {
if (!p || typeof p !== 'object') {
return ''
}
const proc = p as Record<string, unknown>
const status = typeof proc.status === 'string' ? proc.status : 'unknown'
const command = typeof proc.command === 'string' ? proc.command : ''
const sessionId = typeof proc.session_id === 'string' ? proc.session_id : ''
const uptime = proc.uptime
const meta: string[] = []
if (typeof uptime === 'number' && uptime >= 0) {
meta.push(`${uptime}s`)
}
if (sessionId) {
meta.push(sessionId)
}
const tail = meta.length ? ` (${meta.join(' · ')})` : ''
return `• [${status}] ${command}${tail}`
})
.filter(Boolean)
.join('\n')
}
// Generic fallback — keeps us honest if the gateway adds new fields we
// haven't shaped yet.
return `/${name}: ${JSON.stringify(r)}`
}
export function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))

View file

@ -211,6 +211,31 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs'))
})
it('serializes provider selection while a previous choice is pending', async () => {
let resolveSelection: (value: { name: string; ok: boolean; provider: string }) => void = () => undefined
selectToolsetProvider.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSelection = resolve
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
const edge = await screen.findByRole('button', { name: /Microsoft Edge TTS/ })
const elevenlabs = screen.getByRole('button', { name: /ElevenLabs/ })
fireEvent.click(edge)
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'Microsoft Edge TTS'))
expect(elevenlabs.hasAttribute('disabled')).toBe(true)
fireEvent.click(elevenlabs)
expect(selectToolsetProvider).toHaveBeenCalledTimes(1)
resolveSelection({ name: 'tts', ok: true, provider: 'Microsoft Edge TTS' })
await waitFor(() => expect(elevenlabs.hasAttribute('disabled')).toBe(false))
})
it('shows a backend model catalog for image_gen and persists a pick', async () => {
getToolsetConfig.mockResolvedValue(
config({
@ -917,8 +942,13 @@ describe('ToolsetConfigPanel', () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// Expand the keyed provider so its env row renders.
fireEvent.click(await screen.findByRole('button', { name: /ElevenLabs/ }))
// Expand the keyed provider so its env row renders. Wait for the
// selection to commit: on a freshly loaded panel this races the default
// provider initializer, and user intent must win that race.
const elevenLabs = await screen.findByRole('button', { name: /ElevenLabs/ })
fireEvent.click(elevenLabs)
await waitFor(() => expect(elevenLabs.getAttribute('aria-pressed')).toBe('true'))
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })

View file

@ -490,6 +490,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const [activeProvider, setActiveProvider] = useState<string | null>(null)
// Live per-key set/unset state, seeded from the endpoint then patched locally.
const [envState, setEnvState] = useState<Record<string, boolean>>({})
// Default-provider selection and a user click race just after config arrives:
// a stale initialization effect must never replace an explicit choice.
const providerChoiceClaimedRef = useRef(false)
// Guard the Nous Portal sign-in poll loop against unmount/state updates.
const mountedRef = useRef(true)
@ -535,7 +538,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
// the user had already selected another (e.g. DuckDuckGo).
useEffect(() => {
if (activeProvider || providers.length === 0) {
if (providerChoiceClaimedRef.current || activeProvider || providers.length === 0) {
return
}
@ -545,10 +548,19 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
// Claim before enqueueing the state update. Effects can run with a stale
// activeProvider closure after a user click, so state alone is too late to
// protect that choice.
providerChoiceClaimedRef.current = true
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
async function handleSelect(provider: ToolProvider) {
if (selecting !== null) {
return
}
providerChoiceClaimedRef.current = true
setActiveProvider(provider.name)
setSelecting(provider.name)
@ -730,6 +742,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
isActive && 'bg-accent/40'
)}
disabled={selecting !== null}
onClick={() => void handleSelect(provider)}
type="button"
>

View file

@ -1,7 +1,7 @@
import type * as React from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import type { UsageStats } from '@/types/hermes'
import type { SessionMessage, UsageStats } from '@/types/hermes'
export interface ContextSuggestion {
text: string
@ -53,6 +53,31 @@ export interface BrowserManageResponse {
messages?: string[]
}
/** Response from the `session.compress` RPC. `messages` is the post-compress
* history (same shape `session.resume` returns via `_history_to_messages`),
* so the desktop can replace its transcript from it rather than leaving stale
* bubbles on screen. `summary` carries the "compressed N → M messages" line. */
export interface SessionCompressResponse {
host_ack?: {
output?: string
}
info?: {
title?: string
usage?: Partial<UsageStats>
}
messages?: SessionMessage[]
removed?: number
status?: string
summary?: {
aborted?: boolean
headline?: string
noop?: boolean
note?: null | string
token_line?: string
}
usage?: Partial<UsageStats>
}
export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues.

View file

@ -36,6 +36,12 @@ describe('desktop git facade', () => {
$connection.set(null)
})
it('returns undefined after the renderer global is torn down', () => {
vi.stubGlobal('window', undefined)
expect(desktopGit()).toBeUndefined()
})
it('uses Electron git locally', async () => {
$connection.set({ mode: 'local' } as never)

View file

@ -101,5 +101,9 @@ const remoteGit: GitBridge = {
}
export function desktopGit(): GitBridge | undefined {
if (typeof window === 'undefined') {
return undefined
}
return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git
}

View file

@ -81,6 +81,66 @@ describe('desktop slash command curation', () => {
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('routes /compress through the session-compression action', () => {
// /compress must be an action (session.compress RPC), not exec: the slash
// worker route times out on large sessions (#44456).
expect(resolveDesktopCommand('/compress')?.surface).toEqual({ kind: 'action', action: 'compress' })
expect(resolveDesktopCommand('/compress')?.args).toBe(true)
expect(isDesktopSlashCommand('/compress')).toBe(true)
expect(isDesktopSlashSuggestion('/compress')).toBe(true)
expect(desktopSlashUnavailableMessage('/compress')).toBeNull()
// /compact is an alias — executes but stays out of the popover.
expect(resolveDesktopCommand('/compact')?.surface).toEqual({ kind: 'action', action: 'compress' })
expect(isDesktopSlashCommand('/compact')).toBe(true)
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
})
it('routes only stateless session commands through dedicated gateway RPCs', () => {
const expected = {
'/save': 'session.save',
'/status': 'session.status'
} as const
for (const [name, rpcName] of Object.entries(expected)) {
const surface = resolveDesktopCommand(name)?.surface
expect(surface?.kind).toBe('rpc')
if (surface?.kind !== 'rpc') {
continue
}
expect(surface.rpc).toBe(rpcName)
expect(surface.buildParams({ arg: 'topic A', command: name, name: name.slice(1), sessionId: 's-1' })).toEqual({
session_id: 's-1'
})
}
})
it('keeps commands with richer CLI semantics on the slash worker', () => {
for (const name of ['/agents', '/steer', '/stop', '/usage']) {
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
}
})
it('still routes commands without dedicated RPCs through exec()', () => {
const execNames = [
'/background',
'/debug',
'/goal',
'/personality',
'/queue',
'/retry',
'/rollback',
'/tools',
'/undo',
'/version'
]
for (const name of execNames) {
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
}
})
it('routes /journey (and aliases) to the memory graph overlay action', () => {
expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' })

View file

@ -31,6 +31,7 @@ export interface DesktopThemeCommandOption {
export type DesktopActionId =
| 'branch'
| 'browser'
| 'compress'
| 'handoff'
| 'hatch'
| 'help'
@ -55,17 +56,41 @@ export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | '
* - `action` handled by a local client handler (new chat, branch, )
* - `picker` opens an overlay (`/model`, `/resume`); a typed arg is
* resolved by that picker instead of falling through
* - `rpc` dedicated gateway RPC named on the surface. The dispatcher
* calls it directly with the params built by `buildParams`,
* bypassing `slash.exec` / `command.dispatch`. Reserved for
* commands that have a first-class RPC handler in
* `tui_gateway/server.py` (e.g. `/save` session.save).
* - `exec` runs on the backend via slash.exec / command.dispatch and
* renders its text output inline
* renders its text output inline. Only commands WITHOUT a
* dedicated RPC should stay here.
* - `unavailable` a known command with genuinely no desktop UI (terminal-only,
* messaging-only, ); shows a reason instead of executing
*/
export type DesktopCommandSurface =
| { kind: 'action'; action: DesktopActionId }
| { kind: 'picker'; picker: DesktopPickerId }
| {
kind: 'rpc'
rpc: string
timeoutMs?: number
buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>
}
| { kind: 'exec' }
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
/**
* Inputs a `buildParams` function receives. The dispatcher passes session id,
* the typed arg, and the canonical command name so handlers can construct
* the exact JSON the gateway method expects.
*/
export interface SlashCommandBuildCtx {
arg: string
command: string
name: string
sessionId: string
}
export interface DesktopCommandSpec {
/** Canonical command, leading slash included (e.g. `/resume`). */
name: string
@ -92,6 +117,21 @@ const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action'
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
/**
* Route a command directly to its dedicated gateway RPC. Prefer this over
* `exec()` whenever `tui_gateway/server.py` exposes a `@method(...)` for the
* command bypassing `slash.exec` keeps the path short and the response
* structured.
*
* The dispatcher calls `requestGateway(surface.rpc, surface.buildParams(ctx))`
* and then runs `renderRpcResult` to format the response.
*/
const rpc = (
rpcName: string,
buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>,
timeoutMs?: number
): DesktopCommandSurface => ({ kind: 'rpc', rpc: rpcName, timeoutMs, buildParams })
/**
* THE source of truth for desktop slash commands. Everything below execution
* gating, popover suggestions, catalog filtering, pill grouping, and the
@ -140,15 +180,29 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
args: true
},
// Backend-executed commands that render useful inline output
{
name: '/agents',
description: 'Show active desktop sessions and running tasks',
aliases: ['/tasks'],
surface: exec()
},
// Backend-executed commands that render useful inline output.
// Commands with a dedicated gateway RPC (@method in tui_gateway/server.py)
// route to it directly via `rpc(...)` — bypassing slash.exec avoids the
// slash-worker pipe timeout and the "not a quick/plugin/skill command"
// fallback noise for commands the dispatcher doesn't handle inline.
// These commands have gateway RPCs, but their established desktop behavior
// carries richer CLI semantics: /agents includes delegations, /stop cancels
// them, /steer falls back to a next-turn prompt, and /usage is a formatted
// live report. Keep them on slash.exec until their RPC contracts are fully
// equivalent.
{ name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
{ name: '/compress', description: 'Compress this conversation context', aliases: ['/compact'], surface: exec() },
// /compress must be an action (session.compress RPC), not exec: the slash
// worker route times out on large sessions (30s WS / 45s pipe) before the
// LLM summarise call finishes, then command.dispatch surfaces a bogus
// "not a quick/plugin/skill command: compress" (#44456).
{
name: '/compress',
description: 'Compress this conversation context',
aliases: ['/compact'],
surface: action('compress'),
args: true
},
{ name: '/debug', description: 'Create a debug report', surface: exec() },
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
@ -167,9 +221,17 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
{ name: '/save', description: 'Save the current transcript to JSON', surface: exec() },
{ name: '/status', description: 'Show current session status', surface: exec() },
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
{
name: '/save',
description: 'Save the current transcript to JSON',
surface: rpc('session.save', ctx => ({ session_id: ctx.sessionId }))
},
{
name: '/status',
description: 'Show current session status',
surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId }))
},
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec(), args: true },
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
{ name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },

View file

@ -298,7 +298,11 @@ export class JsonRpcGatewayClient {
pending.timer = setTimeout(() => {
if (this.pending.delete(id)) {
detach()
reject(new Error(`request timed out: ${method}`))
// Include the configured timeout so a caller (or a user looking
// at an error toast) can tell whether the default 30s window
// fired or a per-call override — e.g. /compress opts into 120s.
const seconds = Math.round(timeoutMs / 1000)
reject(new Error(`request timed out after ${seconds}s: ${method}`))
}
}, timeoutMs)
}

View file

@ -2501,6 +2501,8 @@ def test_make_agent_passes_configured_fallback_chain(monkeypatch):
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
monkeypatch.setattr(
server,
"_load_cfg",
@ -3807,6 +3809,8 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})
@ -3847,6 +3851,8 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
@ -5879,6 +5885,147 @@ def test_session_compress_uses_compress_helper(monkeypatch):
emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"})
def test_session_compress_normalizes_messages_for_desktop_transcript(monkeypatch):
history = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call-1",
"function": {"name": "read_file", "arguments": '{"path":"secret.txt"}'},
}
],
},
{"role": "tool", "tool_call_id": "call-1", "content": "very sensitive tool output"},
]
agent = types.SimpleNamespace()
server._sessions["sid"] = _session(agent=agent, history=history)
monkeypatch.setattr(server, "_compress_session_history", lambda *_args, **_kwargs: (0, {}))
monkeypatch.setattr(server, "_session_info", lambda *_args: {})
try:
response = server.handle_request(
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
)
finally:
server._sessions.pop("sid", None)
assert response["result"]["messages"] == server._history_to_messages(history)
assert "very sensitive tool output" not in str(response["result"]["messages"])
def test_session_compress_returns_compute_host_history(monkeypatch):
session = _session(agent=None, _compute_host_active=True)
server._sessions["sid"] = session
ack = {
"type": "control.ack",
"output": "Compressed 4 → 2 messages",
"messages": [{"role": "user", "content": "compressed context"}],
"session_info": {"usage": {"total": 42}},
}
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
monkeypatch.setattr(server, "_send_compute_host_control", lambda *args, **kwargs: ack)
try:
resp = server.handle_request(
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
)
finally:
server._sessions.pop("sid", None)
assert resp["result"] == {
"status": "compressed",
"turn_isolation": True,
"host_ack": {key: value for key, value in ack.items() if key != "messages"},
"info": {"usage": {"total": 42}},
"messages": [{"role": "user", "text": "compressed context"}],
"usage": {"total": 42},
}
def test_session_compress_forwards_120_second_budget_to_compute_host(monkeypatch):
session = _session(agent=None, _compute_host_active=True)
server._sessions["sid"] = session
calls = []
def send_control(*args, **kwargs):
calls.append((args, kwargs))
return {
"type": "control.ack",
"result": {
"status": "compressed",
"messages": [],
"removed": 0,
"summary": {"headline": "Already compressed", "noop": True},
},
}
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
monkeypatch.setattr(server, "_send_compute_host_control", send_control)
try:
resp = server.handle_request(
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
)
finally:
server._sessions.pop("sid", None)
assert resp["result"]["status"] == "compressed"
assert calls == [
(
("sid",),
{
"route_name": "session.compress",
"command": "/compress",
"wait": True,
"timeout": 120.0,
},
)
]
def test_session_compress_preserves_compute_host_aborted_summary(monkeypatch):
session = _session(agent=None, _compute_host_active=True)
server._sessions["sid"] = session
result = {
"status": "aborted",
"messages": [{"role": "user", "content": "preserved context"}],
"removed": 0,
"summary": {
"aborted": True,
"headline": "Compression aborted: 6 messages preserved",
"note": "No compression provider is configured.",
},
}
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
monkeypatch.setattr(
server,
"_send_compute_host_control",
lambda *args, **kwargs: {
"type": "control.ack",
"result": result,
"session_key": "rotated-host-key",
"history_version": 7,
"message_count": 1,
"session_info": {"model": "host-model"},
},
)
try:
resp = server.handle_request(
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
)
finally:
server._sessions.pop("sid", None)
assert resp["result"] == {**result, "turn_isolation": True}
assert session["session_key"] == "rotated-host-key"
assert session["history_version"] == 7
assert session["_metadata_message_count"] == 1
assert session["_metadata_mirror"]["model"] == "host-model"
def test_session_compress_reports_aborted_summary_without_success(monkeypatch):
compression_state = types.SimpleNamespace(
_last_compress_aborted=True,
@ -10349,6 +10496,27 @@ def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, t
assert payload["messages"] == history
def test_session_save_proxies_to_compute_host_history(monkeypatch):
"""Isolated turns own history in the host; /save must not export the stale parent mirror."""
sid = "save-host-sid"
server._sessions[sid] = _session(agent=None, _compute_host_active=True)
calls = []
def send_control(control_sid, **kwargs):
calls.append((control_sid, kwargs))
return {"type": "control.ack", "result": {"file": "/tmp/host-save.json"}}
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
monkeypatch.setattr(server, "_send_compute_host_control", send_control)
try:
resp = server._methods["session.save"]("1", {"session_id": sid})
finally:
server._sessions.pop(sid, None)
assert resp["result"] == {"file": "/tmp/host-save.json"}
assert calls == [(sid, {"route_name": "session.save", "wait": True})]
def test_notification_event_dedup_key_preserves_distinct_watch_matches():
"""Watch-match identity includes match content, not just session/type."""
base = {

View file

@ -153,6 +153,7 @@ def test_mutator_route_table_matches_prd_inventory():
"prompt.submit": "turn-path",
"session.interrupt": "turn-path",
"reload.mcp": "run-concurrent",
"session.save": "run-concurrent",
"session.compress": "idle-gated",
"prompt.submit.truncate": "idle-gated",
"slash.model": "idle-gated",
@ -251,6 +252,62 @@ def test_compute_host_compress_control_runs_identity_guard_in_host(monkeypatch):
assert ack["session_info"]["model"] == "host-model"
def test_compute_host_session_compress_returns_structured_result(monkeypatch):
from tui_gateway import server
out = io.StringIO()
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
session = {
"agent": None,
"session_key": "host-key",
"history": [{"role": "user", "content": "preserved"}],
"history_lock": threading.Lock(),
"history_version": 3,
"running": False,
}
calls: list[dict] = []
def compress_handler(_rid, params):
calls.append(params)
return {
"result": {
"status": "aborted",
"messages": [{"role": "user", "content": "preserved"}],
"summary": {"aborted": True, "headline": "Compression aborted"},
}
}
server._sessions["sid"] = session
monkeypatch.setitem(server._methods, "session.compress", compress_handler)
monkeypatch.setattr(server, "_session_info", lambda _agent, _session: {"model": "host-model"})
try:
host.handle_frame(
{
"type": "control",
"sid": "sid",
"request_id": "compress-structured",
"route_name": "session.compress",
"command": "/compress auth",
}
)
ack = _wait_for_frame(
out,
lambda frame: frame.get("type") == "control.ack" and frame.get("request_id") == "compress-structured",
)
finally:
server._sessions.pop("sid", None)
host.close()
assert calls == [{"session_id": "sid", "focus_topic": "auth"}]
assert ack["result"]["status"] == "aborted"
assert ack["result"]["summary"]["aborted"] is True
assert ack["session_key"] == "host-key"
assert ack["history_version"] == 3
assert ack["message_count"] == 1
assert ack["session_info"] == {"model": "host-model"}
def test_append_log_record_single_write_lines(tmp_path):
path = tmp_path / "agent.log"

View file

@ -545,11 +545,75 @@ class ComputeHost:
if route_name == "reload.mcp":
self._handle_reload_mcp({**frame, "type": "reload_mcp"})
return
if route_name == "session.save":
response = server._methods["session.save"](
request_id,
{"session_id": sid},
)
if "error" in response:
self.emit(
{
"type": "control.error",
"sid": sid,
"request_id": request_id,
"message": str(response["error"].get("message") or "session save failed"),
}
)
return
self.emit(
{
"type": "control.ack",
"sid": sid,
"request_id": request_id,
"route_name": route_name,
"result": response.get("result") or {},
}
)
return
if route_name == "session.compress":
command = str(frame.get("command") or "")
focus_topic = command.removeprefix("/compress").strip()
response = server._methods["session.compress"](
request_id,
{
"session_id": sid,
**({"focus_topic": focus_topic} if focus_topic else {}),
},
)
if "error" in response:
self.emit(
{
"type": "control.error",
"sid": sid,
"request_id": request_id,
"message": str(response["error"].get("message") or "session compression failed"),
}
)
return
with session["history_lock"]:
session_key = str(session.get("session_key") or "")
history_version = int(session.get("history_version", 0))
message_count = len(session.get("history") or [])
self.emit(
{
"type": "control.ack",
"sid": sid,
"request_id": request_id,
"route_name": route_name,
"result": response.get("result") or {},
"session_key": session_key,
"history_version": history_version,
"message_count": message_count,
"session_info": server._session_info(session.get("agent"), session),
}
)
return
command = str(frame.get("command") or "")
output = ""
if command:
output = server._mirror_slash_side_effects(sid, session, command)
with session["history_lock"]:
messages = server._history_to_messages(list(session.get("history") or []))
history_version = int(session.get("history_version", 0))
message_count = len(session.get("history") or [])
session_key = str(session.get("session_key") or "")
@ -563,6 +627,7 @@ class ComputeHost:
"session_key": session_key,
"history_version": history_version,
"message_count": message_count,
"messages": messages,
"session_info": server._session_info(session.get("agent"), session),
}
)

View file

@ -32,6 +32,7 @@ MUTATOR_ROUTE_TABLE: dict[str, str] = {
"prompt.submit": "turn-path",
"session.interrupt": "turn-path",
"reload.mcp": "run-concurrent",
"session.save": "run-concurrent",
"session.compress": "idle-gated",
"prompt.submit.truncate": "idle-gated",
"slash.model": "idle-gated",

View file

@ -1431,6 +1431,7 @@ def _send_compute_host_control(
command: str = "",
payload: dict | None = None,
wait: bool = True,
timeout: float = 30.0,
) -> dict:
frame = dict(payload or {})
frame.setdefault("type", "control")
@ -1440,6 +1441,7 @@ def _send_compute_host_control(
route_name=route_name,
payload=frame,
wait=wait,
timeout=timeout,
)
@ -9091,6 +9093,7 @@ def _(rid, params: dict) -> dict:
session, err = _sess_nowait(params, rid)
if err:
return err
assert session is not None
if _session_uses_compute_host(session):
sid = str(params.get("session_id") or "")
focus_topic = str(params.get("focus_topic", "") or "").strip()
@ -9101,13 +9104,38 @@ def _(rid, params: dict) -> dict:
route_name="session.compress",
command=command,
wait=True,
timeout=120.0,
)
except Exception as exc:
return _err(rid, 5019, f"compute-host compress failed: {exc}")
if ack.get("type") in {"control.error", "error"}:
return _err(rid, 4009, str(ack.get("message") or "compute-host compress failed"))
_apply_compute_host_metadata_mirror(session, ack)
return _ok(rid, {"status": "compressed", "turn_isolation": True, "host_ack": ack})
host_result = ack.get("result")
if isinstance(host_result, dict):
# The host owns the isolated session's agent/history, so preserve
# its structured compression result verbatim. In particular this
# carries `status: aborted` and `summary.aborted`; flattening the
# old text-only acknowledgement made Desktop show aborted work as a
# success toast.
return _ok(rid, {**host_result, "turn_isolation": True})
host_info = ack.get("session_info") if isinstance(ack.get("session_info"), dict) else {}
host_messages = _history_to_messages(ack.get("messages")) if isinstance(ack.get("messages"), list) else []
# `messages` is returned at top level for the desktop transcript
# replacement. Keep the host acknowledgement metadata, but do not send
# the same (potentially large) transcript a second time inside it.
host_ack = {key: value for key, value in ack.items() if key != "messages"}
return _ok(
rid,
{
"status": "compressed",
"turn_isolation": True,
"host_ack": host_ack,
"info": host_info,
"messages": host_messages,
"usage": host_info.get("usage") if isinstance(host_info.get("usage"), dict) else {},
},
)
session, err = _sess(params, rid)
if err:
return err
@ -9202,7 +9230,11 @@ def _(rid, params: dict) -> dict:
"summary": summary,
"usage": usage,
"info": info,
"messages": messages,
# Keep this identical to session.resume / session.history:
# raw tool results can contain large or sensitive payloads
# that belong in persisted history, not the transcript
# replacement response.
"messages": _history_to_messages(messages),
},
)
finally:
@ -9224,6 +9256,23 @@ def _(rid, params: dict) -> dict:
if err:
return err
if _session_uses_compute_host(session):
sid = str(params.get("session_id") or "")
try:
ack = _send_compute_host_control(
sid,
route_name="session.save",
wait=True,
)
except Exception as exc:
return _err(rid, 5011, f"compute-host session save failed: {exc}")
if ack.get("type") in {"control.error", "error"}:
return _err(rid, 5011, str(ack.get("message") or "compute-host session save failed"))
result = ack.get("result")
if not isinstance(result, dict):
return _err(rid, 5011, "compute-host session save returned an invalid response")
return _ok(rid, result)
agent = session["agent"]
# Mirror the classic CLI /save: snapshot under the Hermes profile home
# (~/.hermes/sessions/saved/) rather than the project/workspace CWD, and