diff --git a/cli/src/claude/claudeRemote.test.ts b/cli/src/claude/claudeRemote.test.ts index 3fa15c73dc..6482df3f50 100644 --- a/cli/src/claude/claudeRemote.test.ts +++ b/cli/src/claude/claudeRemote.test.ts @@ -1,6 +1,18 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as claudeSdk from '@/claude/sdk'; import type { SDKMessage } from '@/claude/sdk/types'; +import { join } from 'node:path'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { getProjectPath } from '@/claude/utils/path'; +import type { CompactSummaryPayload } from './claudeRemote'; + +vi.mock('@/claude/utils/compactSummaryLookup', () => ({ + findLatestCompactSummary: vi.fn(async () => null) +})); + +import { findLatestCompactSummary } from '@/claude/utils/compactSummaryLookup'; +const findLatestCompactSummaryMock = vi.mocked(findLatestCompactSummary); vi.mock('@/claude/utils/claudeCheckSession', () => ({ claudeCheckSession: () => true @@ -69,6 +81,10 @@ async function waitFor(condition: () => boolean, timeoutMs = 300, intervalMs = 1 } describe('claudeRemote async message handling', () => { + beforeEach(() => { + findLatestCompactSummaryMock.mockReset(); + findLatestCompactSummaryMock.mockImplementation(async () => null); + }); // CI occasionally exceeds the default 5s under load (unrelated to job work). it('reports the initial normal message once after the first result', { timeout: 15_000 }, async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); @@ -374,6 +390,10 @@ describe('claudeRemote async message handling', () => { }); describe('claudeRemote /compact result reporting', () => { + beforeEach(() => { + findLatestCompactSummaryMock.mockReset(); + findLatestCompactSummaryMock.mockImplementation(async () => null); + }); const resultMessage = { type: 'result', subtype: 'success', @@ -385,10 +405,12 @@ describe('claudeRemote /compact result reporting', () => { session_id: 's-1' } as unknown as SDKMessage; + let lastForwarded: SDKMessage[] = []; async function runCompact(sdkMessages: SDKMessage[]): Promise { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); const { claudeRemote } = await import('./claudeRemote'); const completionEvents: string[] = []; + const forwarded: SDKMessage[] = []; queryMock.mockReturnValueOnce(createAsyncStream(sdkMessages)); @@ -415,7 +437,9 @@ describe('claudeRemote /compact result reporting', () => { }, isAborted: () => false, onSessionFound: () => {}, - onMessage: () => {}, + onMessage: (message) => { + forwarded.push(message); + }, onCompletionEvent: (message) => { completionEvents.push(message); }, @@ -426,6 +450,7 @@ describe('claudeRemote /compact result reporting', () => { querySpy.mockRestore(); } + lastForwarded = forwarded; return completionEvents; } @@ -452,9 +477,32 @@ describe('claudeRemote /compact result reporting', () => { resultMessage ]); - expect(completionEvents).toContain('Compaction started'); + expect(completionEvents).toContain('๐Ÿ“ฆ Compaction started'); expect(completionEvents.some((event) => event.includes('Not enough messages to compact.'))).toBe(true); - expect(completionEvents).not.toContain('Compaction completed'); + expect(completionEvents).not.toContain('๐Ÿ“ฆ Compacted'); + }, 15_000); + + it('reports a generic failure without duplicating the fallback text', async () => { + const completionEvents = await runCompact([ + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-1', + uuid: 'u-1' + } as unknown as SDKMessage, + { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'failed', + session_id: 's-1', + uuid: 'u-2' + } as unknown as SDKMessage, + resultMessage + ]); + + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started', '๐Ÿ“ฆ Compaction failed']); }, 15_000); it('still reports success when no failure status arrives', async () => { @@ -469,15 +517,255 @@ describe('claudeRemote /compact result reporting', () => { resultMessage ]); - expect(completionEvents).toEqual(['Compaction started', 'Compaction completed']); + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started', '๐Ÿ“ฆ Compacted']); + }, 15_000); + + it('reports the token delta from the compact_boundary metadata', async () => { + const completionEvents = await runCompact([ + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-1', + uuid: 'u-1' + } as unknown as SDKMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 34492, post_tokens: 2082 }, + session_id: 's-1', + uuid: 'u-2' + } as unknown as SDKMessage, + resultMessage + ]); + + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started', '๐Ÿ“ฆ Compacted (34492 โ†’ 2082 tokens)']); + }, 15_000); + + it('ignores an autonomous result until the compact stream signal arrives', async () => { + const completionEvents = await runCompact([ + resultMessage, + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-1', + uuid: 'u-status' + } as unknown as SDKMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-1', + uuid: 'u-boundary' + } as unknown as SDKMessage, + resultMessage + ]); + + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started', '๐Ÿ“ฆ Compacted (100 โ†’ 10 tokens)']); + }, 15_000); + + it('does not relay the compact_boundary system message during a manual /compact', async () => { + // The boundary is already surfaced by the completion output (summary + // card or token-delta line). Relaying it too renders a second + // "Conversation compacted" event line next to it in the web chat. + await runCompact([ + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-1', + uuid: 'u-1' + } as unknown as SDKMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 34492, post_tokens: 2082 }, + session_id: 's-1', + uuid: 'u-2' + } as unknown as SDKMessage, + resultMessage + ]); + + expect( + lastForwarded.some((m) => m.type === 'system' && (m as { subtype?: string }).subtype === 'compact_boundary') + ).toBe(false); + }, 15_000); + + it('keeps an automatic boundary visible while a manual /compact is pending', async () => { + await runCompact([ + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'auto', pre_tokens: 50000, post_tokens: 3000 }, + session_id: 's-1', + uuid: 'u-auto' + } as unknown as SDKMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 34000, post_tokens: 2000 }, + session_id: 's-1', + uuid: 'u-manual' + } as unknown as SDKMessage, + resultMessage + ]); + + expect( + lastForwarded.some((m) => + m.type === 'system' && + (m as { subtype?: string; compact_metadata?: { trigger?: string } }).subtype === 'compact_boundary' && + (m as { compact_metadata?: { trigger?: string } }).compact_metadata?.trigger === 'auto' + ) + ).toBe(true); + expect(lastForwarded.some((m) => + m.type === 'system' && + (m as { compact_metadata?: { trigger?: string } }).compact_metadata?.trigger === 'manual' + )).toBe(false); + }, 15_000); + + it('does not relay the Compacted stdout echo during a manual /compact', async () => { + // The stdout echo is CLI bookkeeping for the active compact only โ€” + // scoping the suppression here (where the command state lives) keeps + // identical output from other slash commands visible. + await runCompact([ + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-1', + uuid: 'u-1' + } as unknown as SDKMessage, + { + type: 'user', + message: { role: 'user', content: 'Compacted ' } + } as unknown as SDKMessage, + resultMessage + ]); + + expect( + lastForwarded.some((m) => + m.type === 'user' && + (m as { message?: { content?: unknown } }).message?.content === 'Compacted ' + ) + ).toBe(false); }, 15_000); - it('hands compact completion to the ready phase so the result carrier can flush first', async () => { + it('keeps the Compacted stdout echo visible outside a compact turn', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const forwarded: SDKMessage[] = []; + queryMock.mockReturnValueOnce(createAsyncStream([ + { + type: 'user', + message: { role: 'user', content: 'Compacted ' } + } as unknown as SDKMessage, + resultMessage + ])); + + let nextCallCount = 0; + try { + await claudeRemote({ + sessionId: 'session-1', path: process.cwd(), mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) return { message: 'hi', mode: { permissionMode: 'default' } }; + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: (message) => { + forwarded.push(message); + }, + onCompletionEvent: () => {}, + onSessionReset: () => {} + }); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect( + forwarded.some((m) => + m.type === 'user' && + (m as { message?: { content?: unknown } }).message?.content === 'Compacted ' + ) + ).toBe(true); + }, 15_000); + + it('detects a /compact sent on a later turn, not just the initial one', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const completionEvents: string[] = []; + queryMock.mockImplementationOnce(({ prompt }: { prompt: AsyncIterable }) => ({ + async *[Symbol.asyncIterator]() { + const promptIterator = prompt[Symbol.asyncIterator](); + await promptIterator.next(); + yield resultMessage; + // A compact response cannot arrive until the later-turn + // command has actually entered the SDK prompt queue. + await promptIterator.next(); + yield { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-1', + uuid: 'u-2' + } as unknown as SDKMessage; + yield resultMessage; + } + })); + + let nextCallCount = 0; + try { + await claudeRemote({ + sessionId: 'session-1', path: process.cwd(), mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) return { message: 'hi', mode: { permissionMode: 'default' } }; + if (nextCallCount === 2) return { message: '/compact', mode: { permissionMode: 'default' } }; + return null; + }, + onReady: (completionEvent) => { + if (completionEvent) completionEvents.push(completionEvent); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: (message) => { + completionEvents.push(message); + }, + onSessionReset: () => {} + }); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(completionEvents).toContain('๐Ÿ“ฆ Compaction started'); + expect(completionEvents).toContain('๐Ÿ“ฆ Compacted (100 โ†’ 10 tokens)'); + }, 15_000); + + it('flushes the result carrier before publishing compact completion', async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); const { claudeRemote } = await import('./claudeRemote'); const wireOrder: string[] = []; const queued: string[] = []; - queryMock.mockReturnValueOnce(createAsyncStream([resultMessage])); + queryMock.mockReturnValueOnce(createAsyncStream([ + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-1', + uuid: 'u-status' + } as unknown as SDKMessage, + resultMessage + ])); let nextCallCount = 0; try { @@ -499,14 +787,690 @@ describe('claudeRemote /compact result reporting', () => { if (message.type === 'result') queued.push('result'); }, onCompletionEvent: (message) => { - if (message !== 'Compaction started') wireOrder.push(message); + if (message !== '๐Ÿ“ฆ Compaction started') wireOrder.push(message); + } + }); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(wireOrder).toEqual(['result', '๐Ÿ“ฆ Compacted', 'ready']); + }, 15_000); +}); + +describe('claudeRemote compact summary promotion', () => { + beforeEach(() => { + findLatestCompactSummaryMock.mockReset(); + findLatestCompactSummaryMock.mockImplementation(async () => null); + }); + + const initMessage = { + type: 'system', + subtype: 'init', + session_id: 's-9' + } as unknown as SDKMessage; + + const resultMessage = { + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 's-9' + } as unknown as SDKMessage; + + let transcriptDir: string | null = null; + + afterEach(async () => { + if (transcriptDir) { + await rm(transcriptDir, { recursive: true, force: true }); + transcriptDir = null; + } + }); + + async function runCompactWithSummary( + mockSummary: string | null + ): Promise<{ completionEvents: string[]; compactSummaries: CompactSummaryPayload[]; contextTokens: Array }> { + findLatestCompactSummaryMock.mockImplementation(async () => mockSummary); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const completionEvents: string[] = []; + const compactSummaries: CompactSummaryPayload[] = []; + const contextTokens: Array = []; + + // The baseline capture stats the transcript at /compact detection; a + // missing file makes the lookup skip promotion, so tests that exercise + // promotion need a real (empty) transcript on disk. + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + + queryMock.mockReturnValueOnce(createAsyncStream([ + initMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 34492, post_tokens: 2082 }, + session_id: 's-9', + uuid: 'u-2' + } as unknown as SDKMessage, + resultMessage + ])); + + let nextCallCount = 0; + try { + await claudeRemote({ + sessionId: 's-9', + path: transcriptDir, + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: '/compact', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: (completionEvent, compactSummary, compactContextTokens) => { + if (completionEvent) completionEvents.push(completionEvent); + if (compactSummary) compactSummaries.push(compactSummary); + contextTokens.push(compactContextTokens); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: (message) => { + completionEvents.push(message); + }, + onSessionReset: () => {} + }); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + return { completionEvents, compactSummaries, contextTokens }; + } + + it('promotes the transcript summary into a structured compact-summary payload', async () => { + const { completionEvents, compactSummaries, contextTokens } = await runCompactWithSummary('The conversation was about X'); + + expect(findLatestCompactSummaryMock).toHaveBeenCalledWith( + expect.stringContaining(join(getProjectPath(transcriptDir!), 's-9.jsonl').slice(-40)), + expect.objectContaining({ minBytes: expect.any(Number) }) + ); + expect(compactSummaries).toEqual([ + { summary: 'The conversation was about X', tokensBefore: 34492, tokensAfter: 2082 } + ]); + expect(contextTokens).toEqual([2082]); + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started']); + }, 15_000); + + it('continues consuming SDK messages while the compact summary is pending', async () => { + const summary = deferred(); + findLatestCompactSummaryMock.mockImplementation(() => summary.promise); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const forwarded: SDKMessage[] = []; + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-stream-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + queryMock.mockReturnValueOnce(createAsyncStream([ + initMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage, + resultMessage, + { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'autonomous' }] }, + session_id: 's-9', + uuid: 'u-autonomous' + } as unknown as SDKMessage + ])); + + let nextCallCount = 0; + const run = claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => nextCallCount++ === 0 + ? { message: '/compact', mode: { permissionMode: 'default' } } + : null, + onReady: () => {}, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: (message) => forwarded.push(message), + onCompletionEvent: () => {}, + onSessionReset: () => {} + }); + + try { + await vi.waitFor(() => { + expect(findLatestCompactSummaryMock).toHaveBeenCalled(); + expect(forwarded.some((message) => message.type === 'assistant')).toBe(true); + }); + summary.resolve(null); + await run; + } finally { + summary.resolve(null); + await run.catch(() => {}); + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); + + it('does not publish a successful compact outcome when summary polling is aborted', async () => { + findLatestCompactSummaryMock.mockImplementation(async (_path, opts) => { + if (opts?.signal?.aborted) return null; + await new Promise((resolve) => { + opts?.signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + return null; + }); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const controller = new AbortController(); + const completionEvents: string[] = []; + let readyCount = 0; + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-signal-abort-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + queryMock.mockReturnValueOnce({ + async *[Symbol.asyncIterator]() { + yield initMessage; + yield { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage; + yield resultMessage; + await new Promise(() => {}); + } + }); + + let nextCallCount = 0; + const run = claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + signal: controller.signal, + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + return nextCallCount === 1 + ? { message: '/compact', mode: { permissionMode: 'default' } } + : { message: 'must stay queued', mode: { permissionMode: 'default' } }; + }, + onReady: () => { + readyCount += 1; + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: (message) => completionEvents.push(message), + onSessionReset: () => {} + }); + + try { + await vi.waitFor(() => expect(findLatestCompactSummaryMock).toHaveBeenCalled()); + controller.abort(); + await run; + } finally { + controller.abort(); + await run.catch(() => {}); + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started']); + expect(readyCount).toBe(0); + expect(nextCallCount).toBe(1); + }, 15_000); + + it('publishes compact outcome before consuming an already queued next prompt', async () => { + const summary = deferred(); + findLatestCompactSummaryMock.mockImplementation(() => summary.promise); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const wireOrder: string[] = []; + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-order-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + + queryMock.mockImplementationOnce(({ prompt }: { prompt: AsyncIterable }) => ({ + async *[Symbol.asyncIterator]() { + const promptIterator = prompt[Symbol.asyncIterator](); + await promptIterator.next(); + yield initMessage; + yield { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage; + yield resultMessage; + await promptIterator.next(); + yield resultMessage; + } + })); + + let nextCallCount = 0; + const run = claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) return { message: '/compact', mode: { permissionMode: 'default' } }; + if (nextCallCount === 2) { + wireOrder.push('next prompt consumed'); + return { message: 'after compact', mode: { permissionMode: 'default' } }; } + return null; + }, + onReady: (_completionEvent, compactSummary) => { + if (compactSummary) wireOrder.push('compact outcome'); + wireOrder.push('ready'); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: () => {}, + onSessionReset: () => {} + }); + + try { + await vi.waitFor(() => expect(findLatestCompactSummaryMock).toHaveBeenCalled()); + expect(nextCallCount).toBe(1); + summary.resolve('summary'); + await run; + } finally { + summary.resolve(null); + await run.catch(() => {}); + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(wireOrder.slice(0, 3)).toEqual(['compact outcome', 'ready', 'next prompt consumed']); + }, 15_000); + + it('publishes deferred compact completion before propagating a later stream failure', async () => { + const summary = deferred(); + findLatestCompactSummaryMock.mockImplementation(() => summary.promise); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-failure-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + queryMock.mockReturnValueOnce({ + async *[Symbol.asyncIterator]() { + yield initMessage; + yield { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage; + yield resultMessage; + throw new Error('stream failed'); + } + }); + + let nextCallCount = 0; + let readyCount = 0; + let acceptedCount = 0; + const readyEvents: Array = []; + const completionEvents: string[] = []; + const run = claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) return { message: '/compact', mode: { permissionMode: 'default' } }; + return { message: 'must stay queued', mode: { permissionMode: 'default' } }; + }, + onReady: (completionEvent) => { + readyCount += 1; + readyEvents.push(completionEvent); + }, + onCompactResultAccepted: () => { + acceptedCount += 1; + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: (message) => completionEvents.push(message), + onSessionReset: () => {} + }); + try { + await vi.waitFor(() => expect(findLatestCompactSummaryMock).toHaveBeenCalled()); + summary.resolve(null); + await expect(run).rejects.toThrow('stream failed'); + } finally { + summary.resolve(null); + await run.catch(() => {}); + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(nextCallCount).toBe(1); + expect(readyCount).toBe(1); + expect(acceptedCount).toBe(1); + expect(readyEvents).toEqual(['๐Ÿ“ฆ Compacted (100 โ†’ 10 tokens)']); + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started']); + }, 15_000); + + it('does not consume the next prompt when completion and stream failure settle together', async () => { + findLatestCompactSummaryMock.mockImplementation(async () => null); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-failure-tie-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + queryMock.mockReturnValueOnce({ + async *[Symbol.asyncIterator]() { + yield initMessage; + yield { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage; + yield resultMessage; + throw new Error('stream failed in tie'); + } + }); + + let nextCallCount = 0; + const readyEvents: Array = []; + try { + await expect(claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + return nextCallCount === 1 + ? { message: '/compact', mode: { permissionMode: 'default' } } + : { message: 'must stay queued', mode: { permissionMode: 'default' } }; + }, + onReady: (completionEvent) => { + readyEvents.push(completionEvent); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: () => {}, + onSessionReset: () => {} + })).rejects.toThrow('stream failed in tie'); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(readyEvents).toEqual(['๐Ÿ“ฆ Compacted (100 โ†’ 10 tokens)']); + expect(nextCallCount).toBe(1); + }, 15_000); + + it('propagates a rejected compact onReady callback to the response attempt', async () => { + findLatestCompactSummaryMock.mockImplementation(async () => 'summary'); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-ready-error-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + queryMock.mockImplementationOnce(createQueryThatMirrorsPromptErrors([ + initMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage, + resultMessage + ])); + + let nextCallCount = 0; + try { + await expect(claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) return { message: '/compact', mode: { permissionMode: 'default' } }; + return { message: 'must stay queued', mode: { permissionMode: 'default' } }; + }, + onReady: async () => { + throw new Error('ready failed'); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: () => {}, + onSessionReset: () => {} + })).rejects.toThrow('ready failed'); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(nextCallCount).toBe(1); + }, 15_000); + + it('propagates compact completion failure after the prompt iterable has ended', async () => { + findLatestCompactSummaryMock.mockImplementation(async () => 'summary'); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-ended-prompt-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + + queryMock.mockImplementationOnce(({ prompt }: { prompt: AsyncIterable }) => { + const responseError = deferred(); + return { + setError(error: Error) { + responseError.reject(error); + }, + async *[Symbol.asyncIterator]() { + const promptIterator = prompt[Symbol.asyncIterator](); + await promptIterator.next(); + await promptIterator.return?.(); + yield initMessage; + yield { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage; + yield resultMessage; + await responseError.promise; + } + }; + }); + + try { + await expect(claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => ({ message: '/compact', mode: { permissionMode: 'default' } }), + onReady: async () => { + throw new Error('ready failed after prompt end'); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: () => {}, + onSessionReset: () => {} + })).rejects.toThrow('ready failed after prompt end'); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); + + it('cancels deferred compact completion when an aborted tool exits the stream loop', async () => { + findLatestCompactSummaryMock.mockImplementation(async (_path, opts) => { + if (opts?.signal?.aborted) return null; + await new Promise((resolve) => { + opts?.signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + return null; + }); + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + transcriptDir = await mkdtemp(join(tmpdir(), 'claude-compact-abort-')); + const projectDir = getProjectPath(transcriptDir); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 's-9.jsonl'), ''); + queryMock.mockReturnValueOnce(createAsyncStream([ + initMessage, + { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { trigger: 'manual', pre_tokens: 100, post_tokens: 10 }, + session_id: 's-9', + uuid: 'u-boundary' + } as unknown as SDKMessage, + resultMessage, + { + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-aborted', content: 'cancelled' }] + } + } as unknown as SDKMessage + ])); + + let nextCallCount = 0; + let readyCount = 0; + try { + await claudeRemote({ + sessionId: 's-9', path: transcriptDir, mcpServers: {}, claudeEnvVars: {}, + claudeArgs: [], allowedTools: [], hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) return { message: '/compact', mode: { permissionMode: 'default' } }; + return { message: 'must stay queued', mode: { permissionMode: 'default' } }; + }, + onReady: () => { + readyCount += 1; + }, + isAborted: (toolCallId) => toolCallId === 'tool-aborted', + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: () => {}, + onSessionReset: () => {} }); } finally { queryMock.mockReset(); querySpy.mockRestore(); } - expect(wireOrder).toEqual(['result', 'Compaction completed', 'ready']); + expect(nextCallCount).toBe(1); + expect(readyCount).toBe(0); + }, 15_000); + + it('skips summary promotion entirely when the transcript baseline cannot be established', async () => { + // No transcript file on disk: stat fails, the baseline stays null, and + // reading from offset 0 could promote a previous compaction's summary + // row โ€” so the lookup must not run at all. + findLatestCompactSummaryMock.mockImplementation(async () => 'stale summary'); + const dir = await mkdtemp(join(tmpdir(), 'claude-compact-missing-')); + try { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const completionEvents: string[] = []; + const readyPayloads: Array | undefined> = []; + queryMock.mockReturnValueOnce(createAsyncStream([ + initMessage, + { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: 's-9', + uuid: 'u-status' + } as unknown as SDKMessage, + resultMessage + ])); + + let nextCallCount = 0; + try { + await claudeRemote({ + sessionId: 's-9', + path: dir, + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: '/compact', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: (completionEvent, compactSummary) => { + if (completionEvent) completionEvents.push(completionEvent); + readyPayloads.push(compactSummary as Record | undefined); + }, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onCompletionEvent: (message) => { + completionEvents.push(message); + }, + onSessionReset: () => {} + }); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + + expect(findLatestCompactSummaryMock).not.toHaveBeenCalled(); + expect(readyPayloads).toEqual([undefined]); + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started', '๐Ÿ“ฆ Compacted']); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 15_000); + + it('keeps the token delta fallback line when the transcript never yields a summary', async () => { + const { completionEvents, compactSummaries, contextTokens } = await runCompactWithSummary(null); + + expect(compactSummaries).toEqual([]); + expect(contextTokens).toEqual([2082]); + expect(completionEvents).toEqual(['๐Ÿ“ฆ Compaction started', '๐Ÿ“ฆ Compacted (34492 โ†’ 2082 tokens)']); }, 15_000); }); diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 8ef9f03f5c..9fb8483752 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -2,6 +2,7 @@ import { EnhancedMode, PermissionMode } from "./loop"; import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' import { claudeCheckSession } from "./utils/claudeCheckSession"; import { join } from 'node:path'; +import { statSync } from 'node:fs'; import { parseSpecialCommand } from "@/parsers/specialCommands"; import { logger } from "@/lib"; import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; @@ -12,6 +13,28 @@ import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; import { filterCatalogAffectingClaudeArgs } from "./sdk/metadataExtractor"; +import { buildCompactCompletionEvent } from "./utils/compactCompletion"; +import { findLatestCompactSummary } from "./utils/compactSummaryLookup"; + +export interface CompactSummaryPayload { + summary: string; + tokensBefore?: number; + tokensAfter?: number; +} + +interface CompactCompletion { + completionEvent?: string; + compactSummary?: CompactSummaryPayload; + contextTokens?: number; +} + +interface ActiveCompact { + baseline: number | null; + failure: string | null; + sawCompactSignal: boolean; + tokensBefore?: number; + tokensAfter?: number; +} export async function claudeRemote(opts: { @@ -30,7 +53,7 @@ export async function claudeRemote(opts: { // Dynamic parameters nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>, - onReady: (completionEvent?: string) => void | Promise, + onReady: (completionEvent?: string, compactSummary?: CompactSummaryPayload, compactContextTokens?: number) => void | Promise, isAborted: (toolCallId: string) => boolean, // Callbacks @@ -38,6 +61,7 @@ export async function claudeRemote(opts: { onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onFirstResult?: (initialMessage: string) => void, + onCompactResultAccepted?: () => void, onCompletionEvent?: (message: string) => void, onSessionReset?: () => void }) { @@ -96,16 +120,63 @@ export async function claudeRemote(opts: { let mode: EnhancedMode = bootstrapMode; let initial: { message: string; mode: EnhancedMode } | null = null; let specialCommand: ReturnType = { type: null }; - // Claude reports the /compact outcome on a `system`/`status` message that - // arrives before the `result` message. Hold it here so the completion event - // can report what actually happened. Stays null unless a failure is - // reported, so an unseen or successful status keeps the success path. - let isCompactCommand = false; - let compactFailure: string | null = null; + // Owns all mutable state from command enqueue through its result. Null + // means no manual compact turn can claim stream status or boundaries. + const compactState: { active: ActiveCompact | null } = { active: null }; + // The local transcript is keyed by the live session id (updated on init), + // not opts.sessionId which can be stale for forked sessions. + let currentSessionId = startFrom; let awaitingForkInit = forkSession; const messages = new PushableAsyncIterable(); + // Success-only: a failed compaction keeps its failure line, an unknown + // session id or any transcript read problem falls back to the plain + // completion line. Never propagates errors into the result flow. + // Null means the baseline could not be established (unknown session id or + // unreadable transcript) โ€” summary promotion is skipped entirely, because + // reading from offset 0 could promote a previous compaction's summary row. + const getTranscriptBytes = (): number | null => { + if (!currentSessionId) return null; + try { + return statSync(join(getProjectPath(opts.path), `${currentSessionId}.jsonl`)).size; + } catch { + return null; + } + }; + const beginCompactCommand = () => { + logger.debug('[claudeRemote] /compact command detected - will process as normal but with compaction behavior'); + // Keep baseline capture, state arming, and command enqueue in one event + // loop turn so an autonomous result cannot claim the pending compact. + compactState.active = { + baseline: getTranscriptBytes(), + failure: null, + sawCompactSignal: false + }; + if (opts.onCompletionEvent) { + opts.onCompletionEvent('๐Ÿ“ฆ Compaction started'); + } + }; + const lookupCompactSummary = async ( + failure: string | null, + sessionId: string | null, + baseline: number | null, + tokensBefore: number | undefined, + tokensAfter: number | undefined, + signal?: AbortSignal + ): Promise => { + if (failure !== null || !sessionId || baseline === null) return undefined; + try { + const transcriptPath = join(getProjectPath(opts.path), `${sessionId}.jsonl`); + const summary = await findLatestCompactSummary(transcriptPath, { minBytes: baseline, signal }); + if (summary === null) return undefined; + return { summary, tokensBefore, tokensAfter }; + } catch (e) { + logger.debug('[claudeRemote] compact summary lookup failed', e); + return undefined; + } + }; + const applyInitialTurn = async (): Promise<{ message: string; mode: EnhancedMode } | null> => { let next: { message: string; mode: EnhancedMode } | null; try { @@ -137,11 +208,7 @@ export async function claudeRemote(opts: { return null; } if (specialCommand.type === 'compact') { - logger.debug('[claudeRemote] /compact command detected - will process as normal but with compaction behavior'); - isCompactCommand = true; - if (opts.onCompletionEvent) { - opts.onCompletionEvent('Compaction started'); - } + beginCompactCommand(); } mode = next.mode; @@ -230,12 +297,18 @@ export async function claudeRemote(opts: { let nextMessageFetchSeq = 0; let streamMessageSeq = 0; let resultSeq = 0; + const compactCompletionAbort = new AbortController(); + let responseClosed = false; + let compactCompletion: Promise | null = null; + const abortCompactCompletion = () => compactCompletionAbort.abort(); + opts.signal?.addEventListener('abort', abortCompactCompletion, { once: true }); + if (opts.signal?.aborted) compactCompletionAbort.abort(); const scheduleNextMessage = () => { - if (nextMessageFetchInFlight || inputEnded) { + if (nextMessageFetchInFlight || inputEnded || responseClosed) { logger.debug( `${debugPrefix} scheduleNextMessage skipped ` + - `(inFlight=${nextMessageFetchInFlight}, inputEnded=${inputEnded})` + `(inFlight=${nextMessageFetchInFlight}, inputEnded=${inputEnded}, responseClosed=${responseClosed})` ); return; } @@ -247,6 +320,7 @@ export async function claudeRemote(opts: { void (async () => { try { const next = await opts.nextMessage(); + if (responseClosed) return; if (!next) { inputEnded = true; messages.end(); @@ -255,7 +329,15 @@ export async function claudeRemote(opts: { ); return; } + const nextSpecialCommand = parseSpecialCommand(next.message); + if (nextSpecialCommand.type === 'compact') { + // /compact can arrive on any turn, not just the initial + // one โ€” arm the compaction tracking here too so later + // turns get the same summary/token completion output. + beginCompactCommand(); + } mode = next.mode; + specialCommand = nextSpecialCommand; messages.push({ type: 'user', message: { role: 'user', content: next.message } }); logger.debug( `${debugPrefix} nextMessage resolved fetchId=${fetchId} elapsedMs=${Date.now() - startedAt} ` + @@ -281,7 +363,87 @@ export async function claudeRemote(opts: { try { logger.debug(`[claudeRemote] Starting to iterate over response`); - for await (const message of response) { + const responseIterator = response[Symbol.asyncIterator](); + let pendingResponseNext: Promise> | null = null; + let pendingResponseDone = false; + let pendingResponseError: unknown; + let hasPendingResponseError = false; + while (true) { + if (!pendingResponseNext) { + pendingResponseDone = false; + pendingResponseError = undefined; + hasPendingResponseError = false; + pendingResponseNext = responseIterator.next().then( + (result) => { + pendingResponseDone = result.done === true; + return result; + }, + (error) => { + pendingResponseError = error; + hasPendingResponseError = true; + throw error; + } + ); + } + let message: SDKMessage; + if (compactCompletion) { + const winner = await Promise.race([ + compactCompletion.then((result) => ({ type: 'compact' as const, result })), + pendingResponseNext.then( + (result) => ({ type: 'response' as const, result }), + (error) => ({ type: 'response-error' as const, error }) + ) + ]); + if (winner.type === 'response-error') { + if (winner.error instanceof AbortError) throw winner.error; + if (opts.signal?.aborted) throw new AbortError('Compaction completion aborted'); + const completion = await compactCompletion; + compactCompletion = null; + await opts.onReady( + completion.completionEvent, + completion.compactSummary, + completion.contextTokens + ); + throw winner.error; + } + if (winner.type === 'compact') { + compactCompletion = null; + await opts.onReady( + winner.result.completionEvent, + winner.result.compactSummary, + winner.result.contextTokens + ); + logger.debug(`${debugPrefix} compact completion published`); + if (hasPendingResponseError) throw pendingResponseError; + if (pendingResponseDone) { + responseClosed = true; + break; + } + scheduleNextMessage(); + continue; + } + pendingResponseNext = null; + if (winner.result.done) { + responseClosed = true; + const completion = await compactCompletion; + compactCompletion = null; + await opts.onReady( + completion.completionEvent, + completion.compactSummary, + completion.contextTokens + ); + break; + } + message = winner.result.value; + } else { + const next = await pendingResponseNext; + pendingResponseNext = null; + if (next.done) { + responseClosed = true; + break; + } + message = next.value; + } streamMessageSeq += 1; logger.debug( `${debugPrefix} stream message #${streamMessageSeq} type=${message.type} ` + @@ -289,8 +451,30 @@ export async function claudeRemote(opts: { ); logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message); - // Handle messages - opts.onMessage(message); + // Handle messages. During a manual /compact the compact_boundary + // system message stays unrelayed: its only web rendering is a + // "Conversation compacted" status line, which would duplicate the + // completion output (summary card or token-delta line) right below + // it. Auto-compact boundaries keep relaying as before. + const compactMetadata = + message.type === 'system' && message.subtype === 'compact_boundary' + ? (message as any).compact_metadata + : undefined; + const isManualCompactBoundary = + compactState.active !== null && compactMetadata?.trigger === 'manual'; + // The stdout echo of the active /compact is CLI bookkeeping for + // this turn โ€” suppress it here where the command state lives, so + // identical output from other slash commands stays visible. + const echo = message.type === 'user' + ? (message as SDKUserMessage).message?.content + : undefined; + const isManualCompactBookkeeping = + compactState.active !== null && + typeof echo === 'string' && + /^\s*Compacted\s*<\/local-command-stdout>$/.test(echo.trim()); + if (!isManualCompactBoundary && !isManualCompactBookkeeping) { + opts.onMessage(message); + } // Handle special system messages if (message.type === 'system' && message.subtype === 'init') { @@ -302,6 +486,7 @@ export async function claudeRemote(opts: { // Session id is still in memory, wait until session file is written to disk // Start a watcher for to detect the session id if (systemInit.session_id) { + currentSessionId = systemInit.session_id; logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`); const projectDir = getProjectPath(opts.path); const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); @@ -326,17 +511,29 @@ export async function claudeRemote(opts: { // Capture the /compact outcome. Only a reported failure is recorded: // anything else leaves the success path untouched, so a status shape // we do not recognise cannot invent a failure. - if (message.type === 'system' && message.subtype === 'status' && isCompactCommand) { + if (message.type === 'system' && message.subtype === 'status' && compactState.active) { const systemStatus = message as SDKSystemMessage; + if (systemStatus.status === 'compacting' || systemStatus.compact_result !== undefined) { + compactState.active.sawCompactSignal = true; + } if (systemStatus.compact_result === 'failed') { const reason = typeof systemStatus.compact_error === 'string' ? systemStatus.compact_error.trim() : ''; - compactFailure = reason.length > 0 ? reason : 'Compaction failed'; - logger.debug(`[claudeRemote] Compaction reported as failed: ${compactFailure}`); + compactState.active.failure = reason; + logger.debug(`[claudeRemote] Compaction reported as failed: ${compactState.active.failure}`); } } + // Capture the compaction token delta from the boundary metadata + // (pre_tokens/post_tokens are the context sizes on each side). + if (isManualCompactBoundary && compactState.active) { + compactState.active.sawCompactSignal = true; + if (typeof compactMetadata?.pre_tokens === 'number') compactState.active.tokensBefore = compactMetadata.pre_tokens; + if (typeof compactMetadata?.post_tokens === 'number') compactState.active.tokensAfter = compactMetadata.post_tokens; + logger.debug(`[claudeRemote] compact_boundary tokens: ${compactState.active.tokensBefore} -> ${compactState.active.tokensAfter}`); + } + // Handle result messages if (message.type === 'result') { resultSeq += 1; @@ -350,18 +547,48 @@ export async function claudeRemote(opts: { opts.onFirstResult?.(initial.message); } - let completionEvent: string | undefined; - if (isCompactCommand) { - completionEvent = compactFailure - ? `Compaction failed: ${compactFailure}` - : 'Compaction completed'; - logger.debug(`[claudeRemote] ${completionEvent}`); - isCompactCommand = false; - compactFailure = null; + if (compactState.active) { + if (!compactState.active.sawCompactSignal) continue; + const compact = compactState.active; + const sessionId = currentSessionId; + const baseline = compact.baseline; + const failure = compact.failure; + const tokensBefore = compact.tokensBefore; + const tokensAfter = compact.tokensAfter; + // Preserve the post-compaction context size even when no + // summary was found: the launcher refreshes the context + // bar with it, since the next real usage only arrives + // with the next model response. + compactState.active = null; + opts.onCompactResultAccepted?.(); + + compactCompletion = (async () => { + const compactSummary = await lookupCompactSummary( + failure, + sessionId, + baseline, + tokensBefore, + tokensAfter, + compactCompletionAbort.signal + ); + if (compactCompletionAbort.signal.aborted) { + throw new AbortError('Compaction completion aborted'); + } + const completionEvent = compactSummary + ? undefined + : buildCompactCompletionEvent(failure, tokensBefore, tokensAfter); + logger.debug(`[claudeRemote] ${compactSummary ? `compact summary promoted (${compactSummary.summary.length} chars)` : completionEvent}`); + return { completionEvent, compactSummary, contextTokens: tokensAfter }; + })(); + continue; } - // Flush the result carrier before completion, then announce ready. - await opts.onReady(completionEvent); + // An autonomous result may arrive while transcript polling is + // pending. The coordinator keeps consuming it, but the compact + // outcome remains the sole owner of ready and the next prompt. + if (compactCompletion) continue; + + await opts.onReady(); logger.debug(`${debugPrefix} onReady emitted for result #${resultSeq}`); // Pull next user message without blocking response stream processing. @@ -386,6 +613,9 @@ export async function claudeRemote(opts: { } logger.debug(`${debugPrefix} response stream exhausted`); } catch (e) { + responseClosed = true; + compactCompletionAbort.abort(); + await Promise.allSettled(compactCompletion ? [compactCompletion] : []); if (e instanceof AbortError) { logger.debug(`[claudeRemote] Aborted`); // Ignore @@ -394,6 +624,12 @@ export async function claudeRemote(opts: { throw e; } } finally { + responseClosed = true; + if (compactCompletion) { + compactCompletionAbort.abort(); + await Promise.allSettled([compactCompletion]); + } + opts.signal?.removeEventListener('abort', abortCompactCompletion); logger.debug( `${debugPrefix} finally ` + `(streamMessages=${streamMessageSeq}, results=${resultSeq}, nextFetches=${nextMessageFetchSeq}, inputEnded=${inputEnded})` diff --git a/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts b/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts index f7edd8db01..1fddb56e71 100644 --- a/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts @@ -141,6 +141,36 @@ describe('claudeRemoteLauncher launch-failure recovery', () => { expect(restoredMessageText).toBe('hello'); }); + it('does not restore /compact after its SDK result was accepted', async () => { + const queue = new MessageQueue2((mode) => JSON.stringify(mode)); + queue.push('/compact', { permissionMode: 'default' }); + + const client = makeClient(); + const session = makeSession(queue, client); + let callCount = 0; + let queueSizeOnSecondAttempt: number | undefined; + + claudeRemoteMock.mockImplementation(async (opts: any) => { + callCount += 1; + if (callCount === 1) { + const msg = await opts.nextMessage(); + expect(msg?.message).toBe('/compact'); + opts.onCompactResultAccepted(); + throw new Error('stream failed after compact result'); + } + + queueSizeOnSecondAttempt = session.queue.size(); + triggerSwitch(client); + throw new Error('stop test'); + }); + + const { claudeRemoteLauncher } = await import('./claudeRemoteLauncher'); + await claudeRemoteLauncher(session); + + expect(callCount).toBe(2); + expect(queueSizeOnSecondAttempt).toBe(0); + }); + it('sends the result carrier before compact completion and ready', async () => { const queue = new MessageQueue2((mode) => JSON.stringify(mode)); queue.push('/compact', { permissionMode: 'default' }); diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 4a99e556b4..ef221a6f70 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -1,7 +1,8 @@ import React from "react"; import { Session } from "./session"; import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; -import { claudeRemote } from "./claudeRemote"; +import { claudeRemote, type CompactSummaryPayload } from "./claudeRemote"; +import { convertAgentMessage } from "@/agent/messageConverter"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; @@ -492,6 +493,13 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { onFirstResult: (initialMessage) => { applySessionTitleFallback(session.client, initialMessage); }, + onCompactResultAccepted: () => { + // The command has completed at the SDK boundary even + // if transcript summary lookup is still pending. A + // later stream failure must not replay /compact. + reachedReadyThisAttempt = true; + inFlightMessage = null; + }, onCompletionEvent: (message: string) => { logger.debug(`[remote]: Completion event: ${message}`); session.client.sendSessionEvent({ type: 'message', message }); @@ -507,7 +515,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { // just asked to clear. session.consumeOneTimeFlags(); }, - onReady: async (completionEvent?: string) => { + onReady: async (completionEvent?: string, compactSummary?: CompactSummaryPayload, compactContextTokens?: number) => { // Reaching ready at all means this attempt is not an // immediate/deterministic failure -- reset the // respawn-storm guard. The turn that led here is no @@ -521,6 +529,26 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { logger.debug(`[remote]: Completion event: ${completionEvent}`); session.client.sendSessionEvent({ type: 'message', message: completionEvent }); } + if (compactSummary) { + logger.debug(`[remote]: Compact summary promoted (${compactSummary.summary.length} chars)`); + session.client.sendSessionEvent({ + type: 'compact-summary', + summary: compactSummary.summary, + tokensBefore: compactSummary.tokensBefore, + estimatedTokensAfter: compactSummary.tokensAfter + }); + } + if (compactContextTokens !== undefined) { + // The status bar keeps the last pre-compaction + // usage until the next model response; refresh + // it with the boundary's post-tokens the same + // way the Pi launcher does. + const convertedUsage = convertAgentMessage( + { type: 'usage', inputTokens: 0, outputTokens: 0, contextTokens: compactContextTokens }, + session.getModel() + ); + if (convertedUsage) session.client.sendAgentMessage(convertedUsage); + } logger.debug( `[claudeRemoteLauncher][async-debug] onReady callback ` + diff --git a/cli/src/claude/utils/compactCompletion.test.ts b/cli/src/claude/utils/compactCompletion.test.ts new file mode 100644 index 0000000000..9bf695ab05 --- /dev/null +++ b/cli/src/claude/utils/compactCompletion.test.ts @@ -0,0 +1,29 @@ +/** + * Tests for the claude /compact completion event builder + */ + +import { describe, it, expect } from 'vitest' +import { buildCompactCompletionEvent } from './compactCompletion' + +describe('buildCompactCompletionEvent', () => { + it('reports failure with its reason', () => { + expect(buildCompactCompletionEvent('context too large', undefined, undefined)) + .toBe('๐Ÿ“ฆ Compaction failed: context too large') + }) + + it('falls back to a generic failure message when no reason is available', () => { + expect(buildCompactCompletionEvent('', undefined, undefined)) + .toBe('๐Ÿ“ฆ Compaction failed') + }) + + it('emits a token delta line when both tokens are known', () => { + expect(buildCompactCompletionEvent(null, 34492, 2082)) + .toBe('๐Ÿ“ฆ Compacted (34492 โ†’ 2082 tokens)') + }) + + it('omits the delta when tokens are missing', () => { + expect(buildCompactCompletionEvent(null, undefined, 2082)).toBe('๐Ÿ“ฆ Compacted') + expect(buildCompactCompletionEvent(null, 34492, undefined)).toBe('๐Ÿ“ฆ Compacted') + expect(buildCompactCompletionEvent(null, undefined, undefined)).toBe('๐Ÿ“ฆ Compacted') + }) +}) diff --git a/cli/src/claude/utils/compactCompletion.ts b/cli/src/claude/utils/compactCompletion.ts new file mode 100644 index 0000000000..5c7258a7a3 --- /dev/null +++ b/cli/src/claude/utils/compactCompletion.ts @@ -0,0 +1,21 @@ +/** + * Builds the chat-visible completion line for a claude /compact turn. + * + * The SDK stream carries no summary body for compactions (measured: only a + * system/compact_boundary with token metadata arrives), so the completion + * event is the defensive fallback line โ€” pi-style token delta when both + * numbers are known, bare acknowledgment otherwise. + */ +export function buildCompactCompletionEvent( + failure: string | null, + tokensBefore?: number, + tokensAfter?: number +): string { + if (failure !== null) { + return failure.length > 0 ? `๐Ÿ“ฆ Compaction failed: ${failure}` : '๐Ÿ“ฆ Compaction failed' + } + if (typeof tokensBefore === 'number' && typeof tokensAfter === 'number') { + return `๐Ÿ“ฆ Compacted (${tokensBefore} โ†’ ${tokensAfter} tokens)` + } + return '๐Ÿ“ฆ Compacted' +} diff --git a/cli/src/claude/utils/compactSummaryLookup.test.ts b/cli/src/claude/utils/compactSummaryLookup.test.ts new file mode 100644 index 0000000000..26d80d625c --- /dev/null +++ b/cli/src/claude/utils/compactSummaryLookup.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { extractCompactSummaryFromTranscript, findLatestCompactSummary } from './compactSummaryLookup'; + +function entry(text: string, isCompactSummary = true): string { + return JSON.stringify({ + type: 'user', + uuid: `u-${Math.random().toString(36).slice(2)}`, + isCompactSummary, + message: { role: 'user', content: text }, + sessionId: 's-1' + }); +} + +describe('extractCompactSummaryFromTranscript', () => { + it('returns the text of the last isCompactSummary entry', () => { + const content = [ + entry('older summary'), + entry('not a summary', false), + entry('newer summary') + ].join('\n'); + + expect(extractCompactSummaryFromTranscript(content)).toBe('newer summary'); + }); + + it('returns null when no entry carries isCompactSummary', () => { + const content = [entry('plain turn', false)].join('\n'); + expect(extractCompactSummaryFromTranscript(content)).toBeNull(); + }); + + it('returns null for empty or malformed lines without throwing', () => { + expect(extractCompactSummaryFromTranscript('')).toBeNull(); + expect(extractCompactSummaryFromTranscript('{broken json\n[]\n')).toBeNull(); + }); + + it('extracts joined text from array-style message content', () => { + const content = JSON.stringify({ + type: 'user', + uuid: 'u-1', + isCompactSummary: true, + message: { role: 'user', content: [{ type: 'text', text: 'part one\n' }, { type: 'text', text: 'part two' }] } + }); + + expect(extractCompactSummaryFromTranscript(content)).toBe('part one\npart two'); + }); +}); + +describe('findLatestCompactSummary', () => { + it('ignores summaries written before the baseline offset', async () => { + const dir = await mkdtemp(join(tmpdir(), 'compact-summary-')); + try { + const filePath = join(dir, 's-1.jsonl'); + const stale = entry('stale summary from a previous compaction') + '\n'; + await writeFile(filePath, stale); + const baselineBytes = Buffer.byteLength(stale, 'utf8'); + // A resumed or second-compact session already carries a summary row. + // With the baseline recorded before the new compaction started, the + // stale row must not satisfy the lookup while the new row is delayed. + const pending = findLatestCompactSummary(filePath, { + attempts: 3, + intervalMs: 5, + sleep: async () => {}, + minBytes: baselineBytes + }); + await new Promise((r) => setTimeout(r, 20)); + expect(await pending).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns only the summary written after the baseline offset', async () => { + const dir = await mkdtemp(join(tmpdir(), 'compact-summary-')); + try { + const filePath = join(dir, 's-1.jsonl'); + const stale = entry('stale summary') + '\n'; + await writeFile(filePath, stale); + const baselineBytes = Buffer.byteLength(stale, 'utf8'); + const pending = findLatestCompactSummary(filePath, { + attempts: 5, + intervalMs: 5, + sleep: async () => {}, + minBytes: baselineBytes + }); + await writeFile(filePath, stale + entry('fresh summary') + '\n'); + await expect(pending).resolves.toBe('fresh summary'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('resolves the summary once the transcript contains an isCompactSummary entry', async () => { + const dir = await mkdtemp(join(tmpdir(), 'compact-summary-')); + try { + const filePath = join(dir, 's-1.jsonl'); + const sleeps: number[] = []; + const pending = findLatestCompactSummary(filePath, { + attempts: 5, + intervalMs: 7, + sleep: async (ms) => { sleeps.push(ms); } + }); + await writeFile(filePath, entry('late summary') + '\n'); + await expect(pending).resolves.toBe('late summary'); + expect(sleeps.length).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns null after exhausting attempts when the entry never appears', async () => { + const dir = await mkdtemp(join(tmpdir(), 'compact-summary-')); + try { + const filePath = join(dir, 'missing.jsonl'); + const summary = await findLatestCompactSummary(filePath, { + attempts: 3, + intervalMs: 1, + sleep: async () => {} + }); + expect(summary).toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('stops polling when aborted', async () => { + const dir = await mkdtemp(join(tmpdir(), 'compact-summary-')); + try { + const controller = new AbortController(); + const pending = findLatestCompactSummary(join(dir, 'missing.jsonl'), { + attempts: 10, + intervalMs: 10_000, + signal: controller.signal + }); + controller.abort(); + await expect(pending).resolves.toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/claude/utils/compactSummaryLookup.ts b/cli/src/claude/utils/compactSummaryLookup.ts new file mode 100644 index 0000000000..d26e8d9fbe --- /dev/null +++ b/cli/src/claude/utils/compactSummaryLookup.ts @@ -0,0 +1,115 @@ +import { open, type FileHandle } from 'node:fs/promises'; +import { RawJSONLinesSchema } from '../types'; + +/** + * Looks up the compact summary body Claude Code writes to the local session + * transcript (`/.jsonl`). The SDK stream carries only + * the boundary metadata, so the transcript is the sole source for the summary + * text. Claude Code may flush it slightly after the result arrives, hence the + * bounded polling. + */ + +export function extractCompactSummaryFromTranscript(content: string): string | null { + let latest: string | null = null; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + let parsedJson: unknown; + try { + parsedJson = JSON.parse(trimmed); + } catch { + continue; + } + const parsed = RawJSONLinesSchema.safeParse(parsedJson); + if (!parsed.success || parsed.data.type !== 'user' || !parsed.data.isCompactSummary) { + continue; + } + const text = extractText((parsed.data.message as { content?: unknown } | undefined)?.content); + if (text !== null) latest = text; + } + return latest; +} + +function extractText(content: unknown): string | null { + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Array.isArray(content)) { + const joined = content + .filter((block): block is { type: 'text'; text: string } => + typeof block === 'object' && block !== null && + (block as any).type === 'text' && typeof (block as any).text === 'string') + .map((block) => block.text.trim()) + .join('\n') + .trim(); + return joined.length > 0 ? joined : null; + } + return null; +} + +export async function findLatestCompactSummary( + transcriptPath: string, + opts?: { + attempts?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise; + signal?: AbortSignal; + // Byte offset recorded before the compaction started. Rows below it + // belong to earlier turns (e.g. a previous compaction's summary in a + // resumed or second-compact session) and must not satisfy this lookup + // while the fresh row is still being flushed. + minBytes?: number; + } +): Promise { + const attempts = opts?.attempts ?? 10; + const intervalMs = opts?.intervalMs ?? 500; + const minBytes = opts?.minBytes ?? 0; + const signal = opts?.signal; + const sleep = opts?.sleep ?? ((ms: number) => new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + })); + for (let attempt = 0; attempt < attempts; attempt++) { + if (signal?.aborted) return null; + let handle: FileHandle | undefined; + try { + // Range-read only the tail written after the baseline: the + // transcript is append-only and can be large, and this poll runs + // every attempt while the compaction result is already in flight. + handle = await open(transcriptPath, 'r'); + const size = (await handle.stat()).size; + if (size > minBytes) { + const buf = Buffer.alloc(size - minBytes); + // FileHandle.read() may fulfill fewer bytes than requested โ€” + // loop until the requested range is fully read (the session + // scanner's incremental reader follows the same contract). + let bytesRead = 0; + while (bytesRead < buf.length) { + const result = await handle.read(buf, bytesRead, buf.length - bytesRead, minBytes + bytesRead); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + const summary = extractCompactSummaryFromTranscript(buf.toString('utf8')); + if (summary !== null) return summary; + } + } catch { + // Missing or unreadable transcript: keep polling until attempts run out. + } finally { + await handle?.close().catch(() => {}); + } + if (attempt < attempts - 1) await sleep(intervalMs); + } + return null; +} diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index cb41b2ea4f..b7685fca65 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -115,6 +115,27 @@ describe('SDKToLogConverter', () => { expect(converter.convert(sdkMessage)?.isMeta).toBeUndefined() }) + + it.each([ + 'Compacted ', + '/context', + 'context window usage', + 'boom' + ])('keeps other local-command CLI output visible for the client contract (%s)', (content) => { + // Local-command output is rendered intentionally as a CLI-output + // block (docs/api/client-contract/messages.md). The compact stdout + // echo is suppressed upstream, in claudeRemote's stream loop, where + // the active-command state lives. + const sdkMessage: SDKUserMessage = { + type: 'user', + message: { + role: 'user', + content + } + } + + expect(converter.convert(sdkMessage)?.isMeta).toBeUndefined() + }) }) describe('Assistant messages', () => { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 298055bc0d..25854337c7 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -283,6 +283,7 @@ export class SDKToLogConverter { logMessage.isMeta = true } + // Check if this is a tool result and add mode if available if (Array.isArray(userMsg.message.content)) { for (const content of userMsg.message.content) { diff --git a/web/src/components/AssistantChat/messages/SystemMessage.test.tsx b/web/src/components/AssistantChat/messages/SystemMessage.test.tsx new file mode 100644 index 0000000000..2f6e530cd3 --- /dev/null +++ b/web/src/components/AssistantChat/messages/SystemMessage.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CompactSummaryCard } from './SystemMessage' + +vi.mock('./MessageTimestamp', () => ({ + MessageTimestamp: () => +})) + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ) +})) + +describe('CompactSummaryCard', () => { + it('renders the header with token delta collapsed by default', () => { + render() + + expect(screen.getByText('Context compacted')).toBeTruthy() + expect(screen.getByText(/34,492/)).toBeTruthy() + expect(screen.queryByTestId('compact-summary-body')).toBeNull() + }) + + it('shows the summary body when expanded', () => { + render() + + const toggle = screen.getByRole('button') + expect(screen.queryByTestId('compact-summary-body')).toBeNull() + + fireEvent.click(toggle) + expect(screen.getByTestId('compact-summary-body')).toHaveTextContent('the summary body') + + fireEvent.click(toggle) + expect(screen.queryByTestId('compact-summary-body')).toBeNull() + }) + + it('reflects the expanded state on aria-expanded', () => { + render() + + const toggle = screen.getByRole('button') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/web/src/components/AssistantChat/messages/SystemMessage.tsx b/web/src/components/AssistantChat/messages/SystemMessage.tsx index 5bc0dd3cce..1aaa28cbce 100644 --- a/web/src/components/AssistantChat/messages/SystemMessage.tsx +++ b/web/src/components/AssistantChat/messages/SystemMessage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { MessagePrimitive, useAuiState } from '@assistant-ui/react' import { getEventPresentation } from '@/chat/presentation' import type { AgentEvent } from '@/chat/types' @@ -15,6 +16,41 @@ function formatTokenDelta(event: AgentEvent | undefined): string | null { return parts.length === 2 ? `${parts[0]} โ†’ ${parts[1]} tokens` : `${parts[0]} tokens` } +// Compaction summaries are long; keep them collapsed by default and let the +// chevron reveal the body on demand. Toggle state is intentionally local and +// non-persistent โ€” revisiting a session collapses the card again. +export function CompactSummaryCard({ delta, text }: { delta: string | null; text: string }) { + const [expanded, setExpanded] = useState(false) + return ( +
+ + {expanded && text ? ( +
+ +
+ ) : null} +
+ ) +} + export function HappySystemMessage() { const role = useAuiState((s) => s.message.role) const messageId = useAuiState((s) => s.message.id) @@ -43,19 +79,7 @@ export function HappySystemMessage() { const delta = formatTokenDelta(compactSummary) return ( -
-
- - Context compacted - {delta ? {delta} : null} - -
- {text ? ( -
- -
- ) : null} -
+
) }