From cb922b018d018ab45c05e27452e6d0bab39da2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 22 May 2026 19:16:53 +0800 Subject: [PATCH] fix: prevent desktop compact state from sticking Desktop compact events can arrive as a start status followed by a null CLI status or by a summary boundary. Preserve the visible transcript while making either completion path clear transient compacting UI so the session cannot remain stuck after auto-compact or cancellation. Constraint: Desktop receives compact state through translated CLI SDK status events. Rejected: Collapse old transcript into a single compact card | hides useful history and caused issue #568. Confidence: high Scope-risk: moderate Directive: Do not drop status=null compact exits without validating desktop chat state transitions. Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:policy Tested: bun run check:native Tested: bun run check:coverage Tested: SKIP_INSTALL=1 PRESERVE_TAURI_TARGET=1 ./desktop/scripts/build-macos-arm64.sh Tested: Real packaged macOS app with GPT-5.5 completed two live turns without staying in running state. Not-tested: Full live auto-compact threshold trigger in packaged app; Tauri sidecar did not inherit CLAUDE_AUTOCOMPACT_PCT_OVERRIDE during manual smoke. Related: https://github.com/NanmiCoder/cc-haha/issues/567 Related: https://github.com/NanmiCoder/cc-haha/issues/568 --- desktop/src/stores/chatStore.test.ts | 129 +++++++++++++++++- desktop/src/stores/chatStore.ts | 46 +++---- src/server/__tests__/ws-memory-events.test.ts | 12 ++ src/server/ws/handler.ts | 3 + 4 files changed, 162 insertions(+), 28 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index e5b87033..2e82f606 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -223,7 +223,7 @@ describe('chatStore history mapping', () => { expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' }) }) - it('maps compact boundary and summary history into one compact card', () => { + it('maps compact boundary and summary history without hiding pre-compact messages', () => { const messages: MessageEntry[] = [ { id: 'old-user', @@ -259,8 +259,18 @@ describe('chatStore history mapping', () => { const mapped = mapHistoryMessagesToUiMessages(messages) - expect(mapped).toHaveLength(1) + expect(mapped).toHaveLength(3) expect(mapped).toMatchObject([ + { + id: 'old-user', + type: 'user_text', + content: 'Build the billing import flow', + }, + { + id: 'old-assistant', + type: 'assistant_text', + content: 'Implemented the flow.', + }, { type: 'compact_summary', title: 'Context compacted', @@ -1920,8 +1930,10 @@ describe('chatStore history mapping', () => { }) const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [] - expect(messages).toHaveLength(1) + expect(messages).toHaveLength(3) expect(messages).toMatchObject([ + { id: 'old-user', type: 'user_text', content: 'Build the billing import flow' }, + { id: 'old-assistant', type: 'assistant_text', content: 'Implemented the flow.' }, { type: 'compact_summary', title: 'Context compacted', @@ -1959,6 +1971,7 @@ describe('chatStore history mapping', () => { type: 'system_notification', subtype: 'compact_boundary', message: 'Context compacted', + data: { trigger: 'manual' }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', @@ -1976,6 +1989,7 @@ describe('chatStore history mapping', () => { expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'compact_summary', + trigger: 'manual', summary: 'Implemented the billing report and verified export behavior.', }, ]) @@ -2016,6 +2030,11 @@ describe('chatStore history mapping', () => { expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('compacting') expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('Compacting conversation') expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + id: 'old-user', + type: 'user_text', + content: 'old context', + }, { type: 'compact_summary', phase: 'compacting', @@ -2024,6 +2043,110 @@ describe('chatStore history mapping', () => { expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running') }) + it('removes the transient compacting card when compaction is canceled', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [ + { id: 'old-user', type: 'user_text', content: 'old context', timestamp: 1 }, + ], + chatState: 'thinking', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'status', + state: 'compacting', + verb: 'Compacting conversation', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'error', + message: 'Compaction canceled.', + code: 'aborted', + }) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.statusVerb).toBe('') + expect(session?.messages).toMatchObject([ + { + id: 'old-user', + type: 'user_text', + content: 'old context', + }, + { + type: 'error', + message: 'Compaction canceled.', + }, + ]) + expect(session?.messages.some((message) => message.type === 'compact_summary' && message.phase === 'compacting')).toBe(false) + expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'error') + }) + + it('removes the transient compacting card when compacting status ends without a boundary', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [ + { id: 'old-user', type: 'user_text', content: 'old context', timestamp: 1 }, + ], + chatState: 'thinking', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'status', + state: 'compacting', + verb: 'Compacting conversation', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'status', + state: 'thinking', + verb: 'Thinking', + }) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.chatState).toBe('thinking') + expect(session?.messages).toMatchObject([ + { + id: 'old-user', + type: 'user_text', + content: 'old context', + }, + ]) + expect(session?.messages.some((message) => message.type === 'compact_summary' && message.phase === 'compacting')).toBe(false) + expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running') + }) + it('tracks API retry status until the request finishes', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 9ffd5ccc..58782872 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -322,20 +322,15 @@ function compactMetadataFromUnknown(data: unknown): Pick>, timestamp: number, ): UIMessage[] { - let existingIndex = -1 - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index]?.type === 'compact_summary') { - existingIndex = index - break - } - } - if (existingIndex >= 0) { - const existing = messages[existingIndex] as CompactSummaryMessage + const existingIndex = messages.length - 1 + const existingMessage = messages[existingIndex] + if (existingMessage?.type === 'compact_summary') { + const existing = existingMessage const next: CompactSummaryMessage = { ...existing, ...update, @@ -361,13 +356,12 @@ function appendOrUpdateCompactSummary( ] } -function collapseToCompactSummary( - messages: UIMessage[], - update: Partial>, - timestamp: number, -): UIMessage[] { - const existing = [...messages].reverse().find((message): message is CompactSummaryMessage => message.type === 'compact_summary') - return appendOrUpdateCompactSummary(existing ? [existing] : [], update, timestamp) +function dropTailCompactingCompactSummary(messages: UIMessage[]): UIMessage[] { + const tail = messages[messages.length - 1] + if (tail?.type === 'compact_summary' && tail.phase === 'compacting') { + return messages.slice(0, -1) + } + return messages } function upsertBackgroundTaskMessage( @@ -1055,7 +1049,7 @@ export const useChatStore = create((set, get) => ({ nextMessages = appendAssistantTextMessage(nextMessages, pendingText, Date.now()) } if (msg.state === 'compacting') { - nextMessages = collapseToCompactSummary( + nextMessages = appendOrUpdateTailCompactSummary( nextMessages, { title: 'Context compacted', @@ -1063,6 +1057,8 @@ export const useChatStore = create((set, get) => ({ }, Date.now(), ) + } else { + nextMessages = dropTailCompactingCompactSummary(nextMessages) } return { chatState: preserveStreamingTurn ? 'streaming' : msg.state, @@ -1074,7 +1070,7 @@ export const useChatStore = create((set, get) => ({ ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null } : {}), ...(msg.state === 'idle' ? { apiRetry: null } : {}), - ...((shouldFlush || msg.state === 'compacting') ? { messages: nextMessages } : {}), + ...(nextMessages !== session.messages ? { messages: nextMessages } : {}), ...(shouldFlush ? { streamingText: '', } : pendingText !== session.streamingText ? { streamingText: pendingText } : {}), @@ -1348,12 +1344,14 @@ export const useChatStore = create((set, get) => ({ if (pendingText.trim()) { newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now()) } + newMessages = dropTailCompactingCompactSummary(newMessages) newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }] return { messages: newMessages, chatState: 'idle', activeThinkingId: null, streamingText: '', + statusVerb: '', pendingPermission: null, pendingComputerUsePermission: null, apiRetry: null, @@ -1439,7 +1437,7 @@ export const useChatStore = create((set, get) => ({ update((session) => ({ chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState, statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb, - messages: collapseToCompactSummary( + messages: appendOrUpdateTailCompactSummary( session.messages, { title: typeof msg.message === 'string' && msg.message.trim() @@ -1456,13 +1454,12 @@ export const useChatStore = create((set, get) => ({ const summary = extractCompactSummaryContent(msg.message) if (summary) { update((session) => ({ - messages: collapseToCompactSummary( + messages: appendOrUpdateTailCompactSummary( session.messages, { title: 'Context compacted', phase: 'complete', summary, - trigger: 'auto', ...compactMetadataFromUnknown(msg.data), }, Date.now(), @@ -2198,7 +2195,7 @@ export function mapHistoryMessagesToUiMessages( const timestamp = new Date(msg.timestamp).getTime() if (msg.type === 'system' && typeof msg.content === 'string') { if (msg.content.trim() === 'Conversation compacted' || msg.content.trim() === 'Context compacted') { - const compactMessages = collapseToCompactSummary( + const compactMessages = appendOrUpdateTailCompactSummary( uiMessages, { title: 'Context compacted', phase: 'complete' }, timestamp, @@ -2244,13 +2241,12 @@ export function mapHistoryMessagesToUiMessages( const compactSummary = extractCompactSummaryContent(msg.content) if (compactSummary) { - const compactMessages = collapseToCompactSummary( + const compactMessages = appendOrUpdateTailCompactSummary( uiMessages, { title: 'Context compacted', phase: 'complete', summary: compactSummary, - trigger: 'auto', }, timestamp, ) diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 4d13f70f..508166d3 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -57,6 +57,18 @@ describe('WebSocket compact events', () => { type: 'system', subtype: 'status', status: null, + }, 'session-1')).toEqual([ + { + type: 'status', + state: 'thinking', + verb: 'Thinking', + }, + ]) + + expect(translateCliMessage({ + type: 'system', + subtype: 'status', + status: 'warming', }, 'session-1')).toEqual([]) }) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 7737b1c9..c894217c 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1284,6 +1284,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa verb: 'Compacting conversation', }] } + if (cliMsg.status == null) { + return [{ type: 'status', state: 'thinking', verb: 'Thinking' }] + } return [] } if (subtype === 'hook_started' || subtype === 'hook_response') {