From 69dd58ce02bfae037a9c062fda45650c5e0a9809 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Fri, 18 Sep 2026 05:59:02 +0900 Subject: [PATCH] fix(claude): finish compaction in ready state --- .../external-session-activity.service.ts | 14 +++++++ .../external-session-activity.service.test.ts | 27 +++++++++++++ .../subcomponents/MessageComponent.test.tsx | 40 +++++++++++++++++++ .../view/subcomponents/MessageComponent.tsx | 35 +++++++++++++++- src/i18n/locales/de/chat.json | 3 +- src/i18n/locales/en/chat.json | 3 +- src/i18n/locales/fr/chat.json | 3 +- src/i18n/locales/it/chat.json | 3 +- src/i18n/locales/ja/chat.json | 3 +- src/i18n/locales/ko/chat.json | 3 +- src/i18n/locales/ru/chat.json | 3 +- src/i18n/locales/tr/chat.json | 3 +- src/i18n/locales/zh-CN/chat.json | 3 +- src/i18n/locales/zh-TW/chat.json | 3 +- 14 files changed, 135 insertions(+), 11 deletions(-) diff --git a/server/modules/providers/services/external-session-activity.service.ts b/server/modules/providers/services/external-session-activity.service.ts index 3d5756a3..5a33efc3 100644 --- a/server/modules/providers/services/external-session-activity.service.ts +++ b/server/modules/providers/services/external-session-activity.service.ts @@ -254,6 +254,14 @@ const isErrorRecord = (record: JsonRecord): boolean => ( || (record.error !== null && record.error !== undefined) ); +const isClaudeLocalCommandUserRecord = (record: JsonRecord, message: JsonRecord | null): boolean => { + if (record.isMeta === true) return true; + const content = typeof message?.content === 'string' ? message.content.trimStart() : ''; + return content.startsWith('') + || content.startsWith('') + || content.startsWith(''); +}; + const parseClaudeEvidence = (records: JsonRecord[]): ExternalSessionParsedActivityEvidence => { let turnEnded = false; for (let index = records.length - 1; index >= 0; index -= 1) { @@ -279,8 +287,14 @@ const parseClaudeEvidence = (records: JsonRecord[]): ExternalSessionParsedActivi continue; } if (type !== 'assistant' && type !== 'user') continue; + // Claude persists the completed /compact result as a synthetic user row. + // It is context for the next turn, not a newly submitted prompt. + if (type === 'user' && record.isCompactSummary === true) { + return evidence('waiting_user', 'none'); + } const role = readString(message?.role) ?? type; if (role === 'user') { + if (isClaudeLocalCommandUserRecord(record, message)) continue; return turnEnded ? evidence('waiting_user', 'none') : evidence('running', 'none'); diff --git a/server/modules/providers/tests/external-session-activity.service.test.ts b/server/modules/providers/tests/external-session-activity.service.test.ts index 741554a9..0b563dc2 100644 --- a/server/modules/providers/tests/external-session-activity.service.test.ts +++ b/server/modules/providers/tests/external-session-activity.service.test.ts @@ -134,6 +134,33 @@ test('Claude turn_duration closes an interrupted input without creating a comple ].join('\n')), 'running'); }); +test('Claude compact summaries leave the session ready without emitting a reply completion', () => { + assert.deepEqual( + parseExternalJsonlActivityEvidence('claude', [ + line({ type: 'system', subtype: 'compact_boundary' }), + line({ + type: 'user', + isCompactSummary: true, + message: { role: 'user', content: 'Compacted conversation context' }, + }), + line({ + type: 'user', + isMeta: true, + message: { role: 'user', content: 'internal' }, + }), + line({ + type: 'user', + message: { role: 'user', content: '/compact' }, + }), + line({ + type: 'user', + message: { role: 'user', content: 'Compacted' }, + }), + ].join('\n')), + { activity: 'waiting_user', terminalOutcome: 'none' }, + ); +}); + test('Claude API overloaded responses are promoted to ERROR evidence', () => { const overloaded = { type: 'error', diff --git a/src/components/chat/view/subcomponents/MessageComponent.test.tsx b/src/components/chat/view/subcomponents/MessageComponent.test.tsx index 19d11a65..086e1162 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.test.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.test.tsx @@ -3,6 +3,7 @@ import test from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import TestRenderer, { act } from 'react-test-renderer'; import '../../../../i18n/config'; import type { ChatMessage } from '../../types/types'; @@ -33,6 +34,45 @@ test('standalone conversation errors keep details collapsed and defer the full b assert.equal((html.match(/>Error { + const message: ChatMessage = { + type: 'assistant', + content: 'private compacted context sentinel', + timestamp: '2026-09-18T00:00:00.000Z', + isCompactSummary: true, + }; + const html = renderMessage(message); + + assert.match(html, /]*\sopen(?:=|\s|>))[^>]*>/); + assert.match(html, /Compacted context/); + assert.doesNotMatch(html, /private compacted context sentinel/); +}); + +test('opening a compact summary disclosure mounts its body', async (t) => { + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create(createElement(MessageComponent, { + message: { + type: 'assistant', + content: 'expanded compacted context sentinel', + timestamp: '2026-09-18T00:00:00.000Z', + isCompactSummary: true, + }, + prevMessage: null, + createDiff: () => [], + provider: 'claude', + })); + }); + t.after(() => act(() => renderer.unmount())); + + assert.doesNotMatch(JSON.stringify(renderer.toJSON()), /expanded compacted context sentinel/); + const details = renderer.root.findByType('details'); + await act(async () => { + details.props.onToggle({ currentTarget: { open: true } }); + }); + assert.match(JSON.stringify(renderer.toJSON()), /expanded compacted context sentinel/); +}); + test('non-Bash tool failures defer full output behind a collapsed disclosure', () => { const html = renderMessage({ type: 'assistant', diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 98a36c3a..37c8e12a 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -69,12 +69,16 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s const { fullToolResult, isLoadingFullToolResult, fullToolResultError, loadFullToolResult } = useFullToolResult(message.sessionId, message.toolId); const [isToolErrorOpen, setIsToolErrorOpen] = useState(false); const [isConversationErrorOpen, setIsConversationErrorOpen] = useState(false); + const [isCompactSummaryOpen, setIsCompactSummaryOpen] = useState(false); useEffect(() => { setIsToolErrorOpen(false); }, [message.sessionId, message.toolId]); useEffect(() => { setIsConversationErrorOpen(false); }, [message.sessionId, message.timestamp, message.content]); + useEffect(() => { + setIsCompactSummaryOpen(false); + }, [message.sessionId, message.timestamp, message.content]); const effectiveToolResult = message.toolResult; const errorContent = String(message.content || ''); const errorSummary = compactErrorSummary(errorContent, t('messageTypes.error')); @@ -90,7 +94,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s const shouldShowAssistantCopyControl = message.type === 'assistant' && assistantCopyContent.trim().length > 0 && !isCommandOrFileEditToolResponse && - !message.isThinking; + !message.isThinking && + !message.isCompactSummary; const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]); @@ -372,6 +377,34 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s + ) : message.isCompactSummary ? ( +
setIsCompactSummaryOpen(event.currentTarget.open)} + > + + + {t('messageTypes.compactSummary')} + + {isCompactSummaryOpen && ( +
+ + {formattedMessageContent} + +
+ +
+
+ )} +
) : message.isThinking ? ( /* Thinking messages — Reasoning component (ai-elements pattern) */ diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index f6aad7b4..0c4a7d52 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -17,7 +17,8 @@ "tool": "Werkzeug", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "Komprimierter Kontext" }, "tools": { "settings": "Werkzeugeinstellungen", diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 76f0740a..b4ad1e85 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "opencode": "OpenCode" + "opencode": "OpenCode", + "compactSummary": "Compacted context" }, "tools": { "settings": "Tool Settings", diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index a0fd57b0..b0f25f58 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -18,7 +18,8 @@ "claude": "Claude", "cursor": "Cursor", "codex": "Codex", - "opencode": "OpenCode" + "opencode": "OpenCode", + "compactSummary": "Contexte compacté" }, "tools": { "settings": "Paramètres de l'outil", diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 3d32019e..a029d150 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -17,7 +17,8 @@ "tool": "Strumento", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "Contesto compattato" }, "tools": { "settings": "Impostazioni strumento", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index b093a17a..3eff49b8 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -17,7 +17,8 @@ "tool": "ツール", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "圧縮されたコンテキスト" }, "tools": { "settings": "ツール設定", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 035b0801..e807c5fb 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -17,7 +17,8 @@ "tool": "도구", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "압축된 대화 내용" }, "tools": { "settings": "도구 설정", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 378291ca..80dd6086 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -17,7 +17,8 @@ "tool": "Инструмент", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "Сжатый контекст" }, "tools": { "settings": "Настройки инструмента", diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index f485d621..f710fb21 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -17,7 +17,8 @@ "tool": "Araç", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "Sıkıştırılmış bağlam" }, "tools": { "settings": "Araç Ayarları", diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index a05f1d76..a4b16efa 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -17,7 +17,8 @@ "tool": "工具", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "已压缩的上下文" }, "tools": { "settings": "工具设置", diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index 6d347b16..d025b9b1 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -17,7 +17,8 @@ "tool": "工具", "claude": "Claude", "cursor": "Cursor", - "codex": "Codex" + "codex": "Codex", + "compactSummary": "已壓縮的上下文" }, "tools": { "settings": "工具設定",