fix(desktop): route /compress through session.compress RPC so transcript updates

The desktop's /compress went through slash.exec, which routes compress to
_live_slash_command_output → _mirror_slash_side_effects. That path compresses
the live session history server-side and returns only a summary string — it
never sends the post-compress message list back to the client. Since the
desktop builds its transcript purely from streaming events
(message.start/delta/complete) and nothing repopulates it after compression,
the summarized bubbles stayed on screen forever, making /compress look like a
no-op (the "✓ compressed N → M messages" line appeared but nothing changed).

The TUI doesn't have this problem — it calls the session.compress RPC
directly, which returns the full post-compress `messages` array, then calls
ctx.transcript.setHistoryItems(r.messages) to replace the transcript.

This change mirrors that path on the desktop:

- Route /compress (and its /compact alias) to a dedicated desktop action
  handler instead of the generic exec surface.
- The handler calls session.compress directly, replaces the transcript from
  the response's `messages` (same shape session.resume returns — handled by
  the existing toChatMessages converter), then renders the summary headline.
- A typed SessionCompressResponse is added for the RPC's return shape.

The busy-guard, focus_topic forwarding, and "nothing to compress" fallback
match both the TUI's session.compress path and the gateway's session.compress
handler (which the slash.exec path was already mirroring via
_mirror_slash_side_effects).
This commit is contained in:
ethernet 2026-07-20 16:24:51 -04:00
parent d7b36070ef
commit 9f8ecfe695
6 changed files with 185 additions and 4 deletions

View file

@ -278,6 +278,83 @@ describe('usePromptActions /title', () => {
})
})
describe('usePromptActions /compress', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('calls session.compress (not slash.exec) and replaces the transcript from the response', async () => {
// Seed a long-looking transcript so we can prove /compress swaps it out
// for the post-compress history the RPC returns — not just prints a line.
$messages.set([
{ id: 'm1', parts: [textPart('old message one')], role: 'user', timestamp: 0 },
{ id: 'm2', parts: [textPart('old message two')], role: 'assistant', timestamp: 0 },
{ id: 'm3', parts: [textPart('old message three')], role: 'user', timestamp: 0 },
{ id: 'm4', parts: [textPart('old message four')], role: 'assistant', timestamp: 0 }
])
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return {
removed: 2,
status: 'compressed',
summary: {
headline: '✓ compressed 4 → 2 messages',
token_line: '~8.2k → ~2.1k tok'
},
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)} refreshSessions={vi.fn(async () => undefined)} requestGateway={requestGateway} />)
await handle!.submitText('/compress')
// Routes through the dedicated session.compress RPC with the runtime id —
// NOT slash.exec, which only returns a summary string and leaves stale
// bubbles on screen (the bug this fixes).
expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.objectContaining({ session_id: RUNTIME_SESSION_ID }))
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
// The transcript was replaced with the post-compress history, so the old
// messages are gone and only the summarized pair remains.
const messages = $messages.get()
expect(messages).toHaveLength(2)
expect(messages.every(m => !m.parts.some(p => 'text' in p && p.text.includes('old message')))).toBe(true)
})
it('forwards a focus topic arg as focus_topic to session.compress', async () => {
$messages.set([{ id: 'm1', parts: [textPart('ctx')], role: 'user', timestamp: 0 }])
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return { removed: 0, status: 'aborted', summary: { headline: 'nothing to compress', noop: true } } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(<Harness onReady={h => (handle = h)} refreshSessions={vi.fn(async () => undefined)} requestGateway={requestGateway} />)
await handle!.submitText('/compress the deployment bug')
expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.objectContaining({ focus_topic: 'the deployment bug' }))
})
})
describe('usePromptActions slash.exec dispatch payloads', () => {
afterEach(() => {
cleanup()

View file

@ -478,6 +478,7 @@ export function usePromptActions({
refreshSessions,
requestGateway,
resumeStoredSession,
setMessages,
startFreshSessionDraft,
submitPromptText
})

View file

@ -2,7 +2,7 @@ import { type MutableRefObject, useCallback } 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,
@ -29,7 +29,7 @@ import {
setYoloActive
} from '@/store/session'
import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
import type { BrowserManageResponse, SessionCompressResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
import {
type GatewayRequest,
@ -64,6 +64,7 @@ interface SlashCommandDeps {
refreshSessions: () => Promise<void>
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
setMessages: (updater: ChatMessage[] | ((current: ChatMessage[]) => ChatMessage[])) => void
startFreshSessionDraft: () => void
submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean>
}
@ -83,6 +84,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
setMessages,
startFreshSessionDraft,
submitPromptText
} = deps
@ -515,6 +517,62 @@ export function useSlashCommand(deps: SlashCommandDeps) {
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /compress (alias /compact) summarizes older turns into a compact
// preamble. Unlike plain slash.exec commands, it mutates the live session
// history server-side — so the desktop must replace its transcript from
// the response's `messages` (the same field session.resume returns) or the
// summarized bubbles stay on screen forever, making /compress look like
// a no-op. Mirrors the TUI's session.compress path: call the dedicated
// RPC, swap the transcript, then show the feedback headline.
compress: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if (busyRef.current) {
renderSlashOutput('session busy — /interrupt the current turn before /compress')
return
}
const focusTopic = ctx.arg.trim()
try {
const result = await requestGateway<SessionCompressResponse>('session.compress', {
session_id: sessionId,
...(focusTopic ? { focus_topic: focusTopic } : {})
})
// 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.
if (Array.isArray(result?.messages)) {
setMessages(toChatMessages(result.messages))
}
const summary = result?.summary
const lines = [summary?.headline, summary?.token_line, summary?.note].filter(
(line): line is string => Boolean(line)
)
if (lines.length > 0) {
renderSlashOutput(lines.join('\n'))
} else if ((result?.removed ?? 0) > 0) {
renderSlashOutput(`compressed ${result?.removed} messages`)
} else {
renderSlashOutput('nothing to compress')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
@ -628,6 +686,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
setMessages,
startFreshSessionDraft,
submitPromptText
]

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
@ -68,6 +68,27 @@ export interface SessionTitleResponse {
session_key?: string
}
/** Response from the `session.compress` RPC. `messages` is the post-compress
* history (same shape `session.resume` returns), so the desktop can replace
* its transcript from it rather than leaving stale bubbles on screen. `summary`
* carries the human-readable "compressed N → M messages" feedback line. */
export interface SessionCompressResponse {
after_messages?: number
after_tokens?: number
before_messages?: number
before_tokens?: number
messages?: SessionMessage[]
removed?: number
status?: string
summary?: {
aborted?: boolean
headline?: string
noop?: boolean
note?: null | string
token_line?: string
}
}
export interface HandoffRequestResponse {
queued?: boolean
session_key?: string

View file

@ -73,6 +73,22 @@ describe('desktop slash command curation', () => {
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('routes /compress through a desktop action that calls session.compress', () => {
// /compress mutates the live session history server-side, so it must call
// the session.compress RPC directly (like the TUI) and replace the
// transcript from the response — not go through slash.exec, which only
// returns a summary string and leaves stale bubbles on screen.
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 /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'
@ -148,7 +149,13 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
surface: exec()
},
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
{ name: '/compress', description: 'Compress this conversation context', surface: exec() },
{
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 },