mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
d7b36070ef
commit
9f8ecfe695
6 changed files with 185 additions and 4 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -478,6 +478,7 @@ export function usePromptActions({
|
|||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
setMessages,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue