mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
commit
4918cd8032
@ -223,7 +223,7 @@ describe('chatStore history mapping', () => {
|
|||||||
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
|
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[] = [
|
const messages: MessageEntry[] = [
|
||||||
{
|
{
|
||||||
id: 'old-user',
|
id: 'old-user',
|
||||||
@ -259,8 +259,18 @@ describe('chatStore history mapping', () => {
|
|||||||
|
|
||||||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||||||
|
|
||||||
expect(mapped).toHaveLength(1)
|
expect(mapped).toHaveLength(3)
|
||||||
expect(mapped).toMatchObject([
|
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',
|
type: 'compact_summary',
|
||||||
title: 'Context compacted',
|
title: 'Context compacted',
|
||||||
@ -1920,8 +1930,10 @@ describe('chatStore history mapping', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
|
const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
|
||||||
expect(messages).toHaveLength(1)
|
expect(messages).toHaveLength(3)
|
||||||
expect(messages).toMatchObject([
|
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',
|
type: 'compact_summary',
|
||||||
title: 'Context compacted',
|
title: 'Context compacted',
|
||||||
@ -1959,6 +1971,7 @@ describe('chatStore history mapping', () => {
|
|||||||
type: 'system_notification',
|
type: 'system_notification',
|
||||||
subtype: 'compact_boundary',
|
subtype: 'compact_boundary',
|
||||||
message: 'Context compacted',
|
message: 'Context compacted',
|
||||||
|
data: { trigger: 'manual' },
|
||||||
})
|
})
|
||||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||||
type: 'system_notification',
|
type: 'system_notification',
|
||||||
@ -1976,6 +1989,7 @@ describe('chatStore history mapping', () => {
|
|||||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
type: 'compact_summary',
|
type: 'compact_summary',
|
||||||
|
trigger: 'manual',
|
||||||
summary: 'Implemented the billing report and verified export behavior.',
|
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]?.chatState).toBe('compacting')
|
||||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('Compacting conversation')
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('Compacting conversation')
|
||||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
id: 'old-user',
|
||||||
|
type: 'user_text',
|
||||||
|
content: 'old context',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'compact_summary',
|
type: 'compact_summary',
|
||||||
phase: 'compacting',
|
phase: 'compacting',
|
||||||
@ -2024,6 +2043,110 @@ describe('chatStore history mapping', () => {
|
|||||||
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
|
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', () => {
|
it('tracks API retry status until the request finishes', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -322,20 +322,15 @@ function compactMetadataFromUnknown(data: unknown): Pick<CompactSummaryMessage,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendOrUpdateCompactSummary(
|
function appendOrUpdateTailCompactSummary(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
update: Partial<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
|
update: Partial<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
): UIMessage[] {
|
): UIMessage[] {
|
||||||
let existingIndex = -1
|
const existingIndex = messages.length - 1
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
const existingMessage = messages[existingIndex]
|
||||||
if (messages[index]?.type === 'compact_summary') {
|
if (existingMessage?.type === 'compact_summary') {
|
||||||
existingIndex = index
|
const existing = existingMessage
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
const existing = messages[existingIndex] as CompactSummaryMessage
|
|
||||||
const next: CompactSummaryMessage = {
|
const next: CompactSummaryMessage = {
|
||||||
...existing,
|
...existing,
|
||||||
...update,
|
...update,
|
||||||
@ -361,13 +356,12 @@ function appendOrUpdateCompactSummary(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseToCompactSummary(
|
function dropTailCompactingCompactSummary(messages: UIMessage[]): UIMessage[] {
|
||||||
messages: UIMessage[],
|
const tail = messages[messages.length - 1]
|
||||||
update: Partial<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
|
if (tail?.type === 'compact_summary' && tail.phase === 'compacting') {
|
||||||
timestamp: number,
|
return messages.slice(0, -1)
|
||||||
): UIMessage[] {
|
}
|
||||||
const existing = [...messages].reverse().find((message): message is CompactSummaryMessage => message.type === 'compact_summary')
|
return messages
|
||||||
return appendOrUpdateCompactSummary(existing ? [existing] : [], update, timestamp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertBackgroundTaskMessage(
|
function upsertBackgroundTaskMessage(
|
||||||
@ -1055,7 +1049,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
nextMessages = appendAssistantTextMessage(nextMessages, pendingText, Date.now())
|
nextMessages = appendAssistantTextMessage(nextMessages, pendingText, Date.now())
|
||||||
}
|
}
|
||||||
if (msg.state === 'compacting') {
|
if (msg.state === 'compacting') {
|
||||||
nextMessages = collapseToCompactSummary(
|
nextMessages = appendOrUpdateTailCompactSummary(
|
||||||
nextMessages,
|
nextMessages,
|
||||||
{
|
{
|
||||||
title: 'Context compacted',
|
title: 'Context compacted',
|
||||||
@ -1063,6 +1057,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
nextMessages = dropTailCompactingCompactSummary(nextMessages)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
chatState: preserveStreamingTurn ? 'streaming' : msg.state,
|
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.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
|
||||||
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
|
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
|
||||||
...(msg.state === 'idle' ? { apiRetry: null } : {}),
|
...(msg.state === 'idle' ? { apiRetry: null } : {}),
|
||||||
...((shouldFlush || msg.state === 'compacting') ? { messages: nextMessages } : {}),
|
...(nextMessages !== session.messages ? { messages: nextMessages } : {}),
|
||||||
...(shouldFlush ? {
|
...(shouldFlush ? {
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
|
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
|
||||||
@ -1348,12 +1344,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
if (pendingText.trim()) {
|
if (pendingText.trim()) {
|
||||||
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())
|
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())
|
||||||
}
|
}
|
||||||
|
newMessages = dropTailCompactingCompactSummary(newMessages)
|
||||||
newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }]
|
newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }]
|
||||||
return {
|
return {
|
||||||
messages: newMessages,
|
messages: newMessages,
|
||||||
chatState: 'idle',
|
chatState: 'idle',
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
|
statusVerb: '',
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
apiRetry: null,
|
apiRetry: null,
|
||||||
@ -1439,7 +1437,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
update((session) => ({
|
update((session) => ({
|
||||||
chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState,
|
chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState,
|
||||||
statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb,
|
statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb,
|
||||||
messages: collapseToCompactSummary(
|
messages: appendOrUpdateTailCompactSummary(
|
||||||
session.messages,
|
session.messages,
|
||||||
{
|
{
|
||||||
title: typeof msg.message === 'string' && msg.message.trim()
|
title: typeof msg.message === 'string' && msg.message.trim()
|
||||||
@ -1456,13 +1454,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
const summary = extractCompactSummaryContent(msg.message)
|
const summary = extractCompactSummaryContent(msg.message)
|
||||||
if (summary) {
|
if (summary) {
|
||||||
update((session) => ({
|
update((session) => ({
|
||||||
messages: collapseToCompactSummary(
|
messages: appendOrUpdateTailCompactSummary(
|
||||||
session.messages,
|
session.messages,
|
||||||
{
|
{
|
||||||
title: 'Context compacted',
|
title: 'Context compacted',
|
||||||
phase: 'complete',
|
phase: 'complete',
|
||||||
summary,
|
summary,
|
||||||
trigger: 'auto',
|
|
||||||
...compactMetadataFromUnknown(msg.data),
|
...compactMetadataFromUnknown(msg.data),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
@ -2198,7 +2195,7 @@ export function mapHistoryMessagesToUiMessages(
|
|||||||
const timestamp = new Date(msg.timestamp).getTime()
|
const timestamp = new Date(msg.timestamp).getTime()
|
||||||
if (msg.type === 'system' && typeof msg.content === 'string') {
|
if (msg.type === 'system' && typeof msg.content === 'string') {
|
||||||
if (msg.content.trim() === 'Conversation compacted' || msg.content.trim() === 'Context compacted') {
|
if (msg.content.trim() === 'Conversation compacted' || msg.content.trim() === 'Context compacted') {
|
||||||
const compactMessages = collapseToCompactSummary(
|
const compactMessages = appendOrUpdateTailCompactSummary(
|
||||||
uiMessages,
|
uiMessages,
|
||||||
{ title: 'Context compacted', phase: 'complete' },
|
{ title: 'Context compacted', phase: 'complete' },
|
||||||
timestamp,
|
timestamp,
|
||||||
@ -2244,13 +2241,12 @@ export function mapHistoryMessagesToUiMessages(
|
|||||||
|
|
||||||
const compactSummary = extractCompactSummaryContent(msg.content)
|
const compactSummary = extractCompactSummaryContent(msg.content)
|
||||||
if (compactSummary) {
|
if (compactSummary) {
|
||||||
const compactMessages = collapseToCompactSummary(
|
const compactMessages = appendOrUpdateTailCompactSummary(
|
||||||
uiMessages,
|
uiMessages,
|
||||||
{
|
{
|
||||||
title: 'Context compacted',
|
title: 'Context compacted',
|
||||||
phase: 'complete',
|
phase: 'complete',
|
||||||
summary: compactSummary,
|
summary: compactSummary,
|
||||||
trigger: 'auto',
|
|
||||||
},
|
},
|
||||||
timestamp,
|
timestamp,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -57,6 +57,18 @@ describe('WebSocket compact events', () => {
|
|||||||
type: 'system',
|
type: 'system',
|
||||||
subtype: 'status',
|
subtype: 'status',
|
||||||
status: null,
|
status: null,
|
||||||
|
}, 'session-1')).toEqual([
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
state: 'thinking',
|
||||||
|
verb: 'Thinking',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(translateCliMessage({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'status',
|
||||||
|
status: 'warming',
|
||||||
}, 'session-1')).toEqual([])
|
}, 'session-1')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1284,6 +1284,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
|||||||
verb: 'Compacting conversation',
|
verb: 'Compacting conversation',
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
if (cliMsg.status == null) {
|
||||||
|
return [{ type: 'status', state: 'thinking', verb: 'Thinking' }]
|
||||||
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
if (subtype === 'hook_started' || subtype === 'hook_response') {
|
if (subtype === 'hook_started' || subtype === 'hook_response') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user