Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [

Check warning on line 168 in apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / Typecheck & Test (apps/desktop)

React Hook useEffect has a missing dependency: 'actions'. Either include it or remove the dependency array
actions.cancelRun,
actions.restoreToMessage,
actions.steerPrompt,
Expand Down Expand Up @@ -278,6 +278,83 @@
})
})

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export function usePromptActions({
refreshSessions,
requestGateway,
resumeStoredSession,
setMessages,
startFreshSessionDraft,
submitPromptText
})
Expand Down
63 changes: 61 additions & 2 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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>
}
Expand All @@ -83,6 +84,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
setMessages,
startFreshSessionDraft,
submitPromptText
} = deps
Expand Down Expand Up @@ -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)}`)
}
}
}

Expand Down Expand Up @@ -628,6 +686,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
setMessages,
startFreshSessionDraft,
submitPromptText
]
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/app/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/lib/desktop-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface DesktopThemeCommandOption {
export type DesktopActionId =
| 'branch'
| 'browser'
| 'compress'
| 'handoff'
| 'hatch'
| 'help'
Expand Down Expand Up @@ -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 },
Expand Down
Loading