diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index f83aa1235725..cac5495fbfcb 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -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 /** @deprecated Use `redirectPrompt`. */ steerPrompt: (text: string) => Promise + submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise submitText: (text: string, options?: SubmitTextOptions) => Promise } @@ -100,7 +112,7 @@ function Harness({ onSeedState?: (state: Record) => void openMemoryGraph?: () => void refreshSessions: () => Promise - requestGateway: (method: string, params?: Record) => Promise + requestGateway: (method: string, params?: Record, timeoutMs?: number) => Promise resumeStoredSession?: (storedSessionId: string) => Promise | void seedMessages?: unknown[] selectedStoredSessionIdRef?: MutableRefObject @@ -166,6 +178,7 @@ function Harness({ act(async () => actions.redirectPrompt(...args)) as Promise, steerPrompt: (...args: Parameters) => act(async () => actions.steerPrompt(...args)) as Promise, + submitTextRaw: actions.submitText, submitText: (...args: Parameters) => act(async () => actions.submitText(...args)) as Promise }) @@ -283,6 +296,511 @@ describe('usePromptActions /title', () => { }) }) +// Helper: extract rendered text parts from captured updateSessionState seeds. +function renderedSeedTexts(seeds: Record[]): 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[] = [] + + const requestGateway = vi.fn(async (method: string, _params?: Record, _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( + (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[] = [] + + const requestGateway = vi.fn(async (method: string, _params?: Record, _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( + (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[] = [] + + 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( + (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( + (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, _timeoutMs?: number) => ({ removed: 0 }) as never + ) + + let handle: HarnessHandle | null = null + await actRender( + (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[] = [] + + 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( + (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[] = [] + + 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( + (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 = { 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 = { 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( + (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 + 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 = { current: 'stored-a' } + const activeSessionIdRef: MutableRefObject = { 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( + (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 + 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( + (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[] = [] + + 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( + (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[] = [] + + 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( + (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[] = [] + + 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( + (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() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index e7d2c3c36f2d..788305889763 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -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( diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index d80c65f6bbf1..78bc00077914 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -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 - appendSessionTextMessage: (sessionId: string, role: ChatMessage['role'], text: string) => void + appendSessionTextMessage: ( + sessionId: string, + role: ChatMessage['role'], + text: string, + storedSessionId?: string | null + ) => void branchCurrentSession: () => Promise busyRef: MutableRefObject copy: Translations['desktop'] @@ -64,8 +84,14 @@ interface SlashCommandDeps { refreshSessions: () => Promise requestGateway: GatewayRequest resumeStoredSession: (storedSessionId: string) => Promise | void + selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise + 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()) + 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> ): Promise => { @@ -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, + ctx: SlashActionCtx + ): Promise { + 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(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( + '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 ] ) } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts index 1acc854aac55..061fd16210d7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts @@ -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('') + }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index b1d41893cca6..3da0f9c983be 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -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: "" } + * - `session.save`: { file: "" } + * - `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 + + 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 + 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 : '')) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx index fcd22440c7b4..a633e892affa 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -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() + + 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() - // 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' }) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 5a60964f2114..f3cee6d3b4dc 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -490,6 +490,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi const [activeProvider, setActiveProvider] = useState(null) // Live per-key set/unset state, seeded from the endpoint then patched locally. const [envState, setEnvState] = useState>({}) + // 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" > diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index e12e92286d07..01ada56e9357 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -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 + } + messages?: SessionMessage[] + removed?: number + status?: string + summary?: { + aborted?: boolean + headline?: string + noop?: boolean + note?: null | string + token_line?: string + } + usage?: Partial +} + 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. diff --git a/apps/desktop/src/lib/desktop-git.test.ts b/apps/desktop/src/lib/desktop-git.test.ts index edd6c26feaf4..68b0b23ed0af 100644 --- a/apps/desktop/src/lib/desktop-git.test.ts +++ b/apps/desktop/src/lib/desktop-git.test.ts @@ -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) diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index ddc14df84dae..9584bb278b06 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -101,5 +101,9 @@ const remoteGit: GitBridge = { } export function desktopGit(): GitBridge | undefined { + if (typeof window === 'undefined') { + return undefined + } + return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git } diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index dfb1bb1d2170..cbca758e7e65 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -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' }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 5974f25ddd4a..3b386e401b31 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -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 + } | { 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, + 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() }, diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 07c7817a05ab..962ba1a9bd41 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -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) } diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 9a197f13acd6..c82ef9452454 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 = { diff --git a/tests/tui_gateway/test_compute_host_phase1.py b/tests/tui_gateway/test_compute_host_phase1.py index 25b9039aed6d..fb1a01728986 100644 --- a/tests/tui_gateway/test_compute_host_phase1.py +++ b/tests/tui_gateway/test_compute_host_phase1.py @@ -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" diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py index 7655932937db..9ff3e100cb38 100644 --- a/tui_gateway/compute_host.py +++ b/tui_gateway/compute_host.py @@ -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), } ) diff --git a/tui_gateway/host_supervisor.py b/tui_gateway/host_supervisor.py index 941d9f69b741..2541e27a7773 100644 --- a/tui_gateway/host_supervisor.py +++ b/tui_gateway/host_supervisor.py @@ -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", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f10eba7fdddc..987790f95ec8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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