Merge compact state fix into local main

Bring the desktop compact state repair from the validation worktree into
local main after focused, full local, packaged-app, and coverage checks.

Constraint: Local main was clean and shared the same base commit as the validation worktree.
Confidence: high
Scope-risk: moderate
Directive: Keep compact status null handling and tail compact-card cleanup together.
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
Not-tested: Full live auto-compact threshold trigger in packaged app; Tauri sidecar did not inherit CLAUDE_AUTOCOMPACT_PCT_OVERRIDE during manual smoke.
Related: cb922b01
This commit is contained in:
程序员阿江(Relakkes) 2026-05-22 19:17:05 +08:00
commit 4918cd8032
4 changed files with 162 additions and 28 deletions

View File

@ -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: {

View File

@ -322,20 +322,15 @@ function compactMetadataFromUnknown(data: unknown): Pick<CompactSummaryMessage,
}
}
function appendOrUpdateCompactSummary(
function appendOrUpdateTailCompactSummary(
messages: UIMessage[],
update: Partial<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
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<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
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<ChatStore>((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<ChatStore>((set, get) => ({
},
Date.now(),
)
} else {
nextMessages = dropTailCompactingCompactSummary(nextMessages)
}
return {
chatState: preserveStreamingTurn ? 'streaming' : msg.state,
@ -1074,7 +1070,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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,
)

View File

@ -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([])
})

View File

@ -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') {