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
264 changes: 262 additions & 2 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { uploadComposerAttachment, usePromptActions } from '.'
Expand Down Expand Up @@ -59,6 +70,7 @@ interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitTextRaw: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean>
submitText: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean>
}

Expand All @@ -85,7 +97,7 @@ function Harness({
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
selectedStoredSessionIdRef?: MutableRefObject<string | null>
Expand Down Expand Up @@ -143,6 +155,7 @@ function Harness({
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitTextRaw: actions.submitText,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
Expand All @@ -158,6 +171,7 @@ describe('usePromptActions /title', () => {

afterEach(() => {
cleanup()
clearNotifications()
vi.restoreAllMocks()
})

Expand Down Expand Up @@ -378,6 +392,252 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
expect($composerDraft.get()).toBe('/ pasted context that must not vanish')
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})

it('keeps the slash-worker failure when command.dispatch only adds unknown-command noise', async () => {
const states: Record<string, unknown>[] = []

const requestGateway = vi.fn(async (method: string) => {
if (method === 'slash.exec') {
throw new Error('slash worker timed out')
}

if (method === 'command.dispatch') {
throw new Error('not a quick/plugin/skill command: status')
}

return {} as never
})

let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => states.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)

await handle!.submitText('/status')

const renderedText = states
.flatMap(state => state.messages as Array<{ parts?: Array<{ text?: string }> }>)
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
.join('\n')

expect(renderedText).toContain('error: /status failed: slash worker timed out')
expect(renderedText).not.toContain('not a quick/plugin/skill command: status')
})
})

describe('usePromptActions /compress session isolation', () => {
const RUNTIME_SESSION_B = 'rt-session-b'

afterEach(() => {
cleanup()
clearNotifications()
setCurrentUsage({ calls: 0, input: 0, output: 0, total: 0 })
setMessages([])
vi.restoreAllMocks()
})

it('does not replace the foreground transcript or usage when compression resolves after a session switch', async () => {
let resolveCompress: (value: unknown) => void = () => undefined

const compressResult = new Promise(resolve => {
resolveCompress = resolve
})

const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }

const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}

throw new Error(`unexpected method: ${method}`)
})

setMessages([{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user' }])
setCurrentUsage({ calls: 7, input: 70, output: 30, total: 100 })

let handle: HarnessHandle | null = null
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)

const submitted = handle!.submitText('/compress')
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('session.compress', { session_id: RUNTIME_SESSION_ID }, 0)
)

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 submitted

expect($messages.get()).toEqual([{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user' }])
expect($currentUsage.get()).toEqual({ calls: 7, input: 70, output: 30, total: 100 })
})

it('leaves the target transcript equal to the authoritative compression result', async () => {
const states: Record<string, unknown>[] = []

const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return {
usage: { context_used: 4_000, total: 12_000 },
messages: [{ content: 'compressed transcript', role: 'system' }],
removed: 5,
summary: {
headline: 'Compressed: 8 → 3 messages',
token_line: 'Approx request size: ~12,000 → ~4,000 tokens'
}
} as never
}

throw new Error(`unexpected method: ${method}`)
})

let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => states.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)

await handle!.submitText('/compress')

expect(requestGateway).toHaveBeenCalledWith('session.compress', { session_id: RUNTIME_SESSION_ID }, 0)
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())

const finalMessages = states[states.length - 1]?.messages as Array<{ parts?: Array<{ text?: string }> }>
const renderedText = finalMessages
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
.join('\n')

expect(renderedText).toBe('compressed transcript')
expect($currentUsage.get()).toEqual(expect.objectContaining({ context_used: 4_000, total: 12_000 }))
})

it('coalesces repeated compression requests for one runtime session', async () => {
let resolveCompress: (value: unknown) => void = () => undefined
const compressResult = new Promise(resolve => {
resolveCompress = resolve
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}

throw new Error(`unexpected method: ${method}`)
})

let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)

const first = handle!.submitTextRaw('/compress')
await waitFor(() => expect(requestGateway).toHaveBeenCalledTimes(1))

const second = handle!.submitTextRaw('/compress')
try {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
await waitFor(() => expect(requestGateway).toHaveBeenCalledTimes(1))
} finally {
resolveCompress({ messages: [{ content: 'compressed transcript', role: 'system' }] })
await Promise.all([first, second])
}
})

it('shows compression progress outside the authoritative transcript', async () => {
let resolveCompress: (value: unknown) => void = () => undefined
const compressResult = new Promise(resolve => {
resolveCompress = resolve
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}

throw new Error(`unexpected method: ${method}`)
})

let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)

const submitted = handle!.submitTextRaw('/compress')
await waitFor(() => expect($notifications.get().some(item => item.message === 'compressing context...')).toBe(true))
resolveCompress({ messages: [{ content: 'compressed transcript', role: 'system' }] })
await submitted
})

it('passes a focus topic to session.compress', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return { removed: 0 } as never
}

throw new Error(`unexpected method: ${method}`)
})

let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)

await handle!.submitText('/compress the auth refactor')

expect(requestGateway).toHaveBeenCalledWith(
'session.compress',
{ focus_topic: 'the auth refactor', session_id: RUNTIME_SESSION_ID },
0
)
})

it('renders a session.compress busy error inline', async () => {
const states: Record<string, unknown>[] = []

const requestGateway = vi.fn(async () => {
throw new Error('session busy — /interrupt the current turn before /compress')
})

let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => states.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)

await handle!.submitText('/compress')

const renderedText = states
.flatMap(state => state.messages as Array<{ parts?: Array<{ text?: string }> }>)
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
.join('\n')

expect(renderedText).toContain('error: session busy — /interrupt the current turn before /compress')
})
})

describe('usePromptActions desktop slash pickers', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,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 = selectedStoredSessionIdRef.current) => {
// 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
Expand All @@ -234,7 +234,7 @@ export function usePromptActions({
}
]
}),
selectedStoredSessionIdRef.current
storedSessionId
)
},
[selectedStoredSessionIdRef, updateSessionState]
Expand Down Expand Up @@ -465,8 +465,10 @@ export function usePromptActions({
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText
submitPromptText,
updateSessionState
})

const submitText = useCallback(
Expand Down
Loading