diff --git a/apps/claude-sdk-cli/src/replayHistory.ts b/apps/claude-sdk-cli/src/replayHistory.ts index 1416971..6e827e5 100644 --- a/apps/claude-sdk-cli/src/replayHistory.ts +++ b/apps/claude-sdk-cli/src/replayHistory.ts @@ -18,7 +18,7 @@ export type ReplayBlock = { * Mapping: * user text blocks → prompt block * user tool_result blocks → tools block "↩ N results" (appended if tools block already open) - * user compaction block → compaction block with summary text + * asst compaction block → compaction block with summary text * asst text blocks → response block * asst thinking blocks → thinking block (only if opts.showThinking) * asst tool_use blocks → tools block "→ name" (merged into running tools block) @@ -42,13 +42,6 @@ export function replayHistory(messages: Anthropic.Beta.Messages.BetaMessageParam const content = Array.isArray(message.content) ? message.content : [{ type: 'text' as const, text: message.content as string }]; if (message.role === 'user') { - // Compaction takes priority — a compaction message has only a compaction block. - const compaction = content.find((b) => b.type === 'compaction') as { type: 'compaction'; summary?: string } | undefined; - if (compaction) { - blocks.push({ type: 'compaction', content: compaction.summary ?? '' }); - continue; - } - // Tool results — count only, name not available without cross-referencing tool_use ids. const resultCount = content.filter((b) => b.type === 'tool_result').length; if (resultCount > 0) { @@ -82,6 +75,9 @@ export function replayHistory(messages: Anthropic.Beta.Messages.BetaMessageParam } else if (block.type === 'tool_use') { const name = (block as { type: 'tool_use'; name: string }).name; appendToTools(`→ ${name}`); + } else if (block.type === 'compaction') { + const compaction = block as { type: 'compaction'; content: string }; + blocks.push({ type: 'compaction', content: compaction.content }); } } } diff --git a/apps/claude-sdk-cli/test/replayHistory.spec.ts b/apps/claude-sdk-cli/test/replayHistory.spec.ts index 7df61e5..7a53406 100644 --- a/apps/claude-sdk-cli/test/replayHistory.spec.ts +++ b/apps/claude-sdk-cli/test/replayHistory.spec.ts @@ -1,4 +1,5 @@ import type { Anthropic } from '@anthropic-ai/sdk'; +import type { BetaThinkingBlockParam, BetaToolResultBlockParam, BetaToolUseBlockParam } from '@anthropic-ai/sdk/resources/beta.js'; import { describe, expect, it } from 'vitest'; import { replayHistory } from '../src/replayHistory.js'; @@ -12,13 +13,13 @@ const user = (text: string): Msg => ({ role: 'user', content: [{ type: 'text', t const assistant = (text: string): Msg => ({ role: 'assistant', content: [{ type: 'text', text }] }); -const toolUse = (name: string): Anthropic.Beta.Messages.BetaContentBlockParam => ({ type: 'tool_use', id: `tu_${name}`, name, input: {} }) as unknown as Anthropic.Beta.Messages.BetaContentBlockParam; +const toolUse = (name: string): BetaToolUseBlockParam => ({ type: 'tool_use', id: `tu_${name}`, name, input: {} }) satisfies BetaToolUseBlockParam; -const toolResult = (id: string): Anthropic.Beta.Messages.BetaContentBlockParam => ({ type: 'tool_result', tool_use_id: id, content: 'ok' }) as unknown as Anthropic.Beta.Messages.BetaContentBlockParam; +const toolResult = (id: string): BetaToolResultBlockParam => ({ type: 'tool_result', tool_use_id: id, content: 'ok' }) satisfies BetaToolResultBlockParam; -const thinking = (text: string): Anthropic.Beta.Messages.BetaContentBlockParam => ({ type: 'thinking', thinking: text, signature: 'sig' }) as unknown as Anthropic.Beta.Messages.BetaContentBlockParam; +const thinking = (text: string): BetaThinkingBlockParam => ({ type: 'thinking', thinking: text, signature: 'sig' }) satisfies BetaThinkingBlockParam; -const compaction = (summary: string): Msg => ({ role: 'user', content: [{ type: 'compaction', summary, llm_identifier: 'claude-3-5-sonnet-20241022' }] }) as unknown as Msg; +const compaction = (content: string | null): Msg => ({ role: 'assistant', content: [{ type: 'compaction' as const, content }] }); const noThinking = { showThinking: false }; const withThinking = { showThinking: true }; @@ -52,18 +53,6 @@ describe('replayHistory — user messages', () => { expect(actual).toBe(expected); }); - it('compaction produces a compaction block', () => { - const expected = 'compaction'; - const actual = replayHistory([compaction('summary text')], noThinking)[0]?.type; - expect(actual).toBe(expected); - }); - - it('compaction block carries the summary text', () => { - const expected = 'summary text'; - const actual = replayHistory([compaction('summary text')], noThinking)[0]?.content; - expect(actual).toBe(expected); - }); - it('tool results produce a tools block', () => { const msg: Msg = { role: 'user', content: [toolResult('tu_1'), toolResult('tu_2')] }; const expected = 'tools'; @@ -130,6 +119,18 @@ describe('replayHistory — assistant messages', () => { const actual = replayHistory([msg], noThinking)[0]?.content; expect(actual).toBe(expected); }); + + it('compaction produces a compaction block', () => { + const expected = 'compaction'; + const actual = replayHistory([compaction('summary text')], noThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('compaction block carries the summary text', () => { + const expected = 'summary text'; + const actual = replayHistory([compaction('summary text')], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/claude-sdk/src/private/MessageStream.ts b/packages/claude-sdk/src/private/MessageStream.ts index 5fea9b3..7fe4dcb 100644 --- a/packages/claude-sdk/src/private/MessageStream.ts +++ b/packages/claude-sdk/src/private/MessageStream.ts @@ -106,8 +106,10 @@ export class MessageStream extends EventEmitter { this.#completed.push({ type: 'tool_use', id: acc.id, name: acc.name, input: acc.partialJson.length > 0 ? JSON.parse(acc.partialJson) : {} }); break; case 'compaction': - this.#completed.push({ type: 'compaction', content: acc.content }); - this.emit('compaction_complete', acc.content); + if (acc.content) { + this.#completed.push({ type: 'compaction', content: acc.content }); + } + this.emit('compaction_complete', acc.content || 'No compaction summary received'); break; } break; @@ -138,7 +140,7 @@ export class MessageStream extends EventEmitter { break; case 'compaction_delta': if (this.#current?.type === 'compaction') { - this.#current.content += event.delta.content; + this.#current.content += event.delta.content ?? ''; } break; case 'citations_delta': diff --git a/packages/claude-sdk/test/Conversation.spec.ts b/packages/claude-sdk/test/Conversation.spec.ts index 526dca6..6260dbf 100644 --- a/packages/claude-sdk/test/Conversation.spec.ts +++ b/packages/claude-sdk/test/Conversation.spec.ts @@ -14,9 +14,9 @@ function msg(role: Role, text: string): Anthropic.Beta.Messages.BetaMessageParam function compactionMsg(): Anthropic.Beta.Messages.BetaMessageParam { return { - role: 'user', - content: [{ type: 'compaction', summary: 'summary', llm_identifier: 'claude-3-5-sonnet-20241022' }], - } as unknown as Anthropic.Beta.Messages.BetaMessageParam; + role: 'assistant', + content: [{ type: 'compaction', content: 'summary' }], + }; } function texts(conversation: Conversation): (string | undefined)[] { diff --git a/packages/claude-sdk/test/MessageStream.spec.ts b/packages/claude-sdk/test/MessageStream.spec.ts new file mode 100644 index 0000000..cc580f6 --- /dev/null +++ b/packages/claude-sdk/test/MessageStream.spec.ts @@ -0,0 +1,84 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import { describe, expect, it } from 'vitest'; +import { MessageStream } from '../src/private/MessageStream.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function* makeStream(events: Anthropic.Beta.Messages.BetaRawMessageStreamEvent[]): AsyncIterable { + yield* events; +} + +const startCompaction: Anthropic.Beta.Messages.BetaRawMessageStreamEvent = { + type: 'content_block_start', + index: 0, + content_block: { type: 'compaction', content: null }, +}; + +const stopCompaction: Anthropic.Beta.Messages.BetaRawMessageStreamEvent = { + type: 'content_block_stop', + index: 0, +}; + +function deltaCompaction(content: string | null): Anthropic.Beta.Messages.BetaRawMessageStreamEvent { + return { type: 'content_block_delta', index: 0, delta: { type: 'compaction_delta', content } }; +} + +// --------------------------------------------------------------------------- +// Null compaction content +// --------------------------------------------------------------------------- + +describe('MessageStream — null compaction content', () => { + it('does not produce a compaction block in result.blocks', async () => { + const stream = new MessageStream(); + const result = await stream.process(makeStream([startCompaction, deltaCompaction(null), stopCompaction])); + const expected = false; + const actual = result.blocks.some((b) => b.type === 'compaction'); + expect(actual).toBe(expected); + }); + + it('still emits compaction_complete with fallback message', async () => { + const stream = new MessageStream(); + let emitted: string | undefined; + stream.on('compaction_complete', (summary) => { + emitted = summary; + }); + await stream.process(makeStream([startCompaction, deltaCompaction(null), stopCompaction])); + const expected = 'No compaction summary received'; + expect(emitted).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Valid compaction content +// --------------------------------------------------------------------------- + +describe('MessageStream — valid compaction content', () => { + it('produces a compaction block in result.blocks', async () => { + const stream = new MessageStream(); + const result = await stream.process(makeStream([startCompaction, deltaCompaction('Session summary'), stopCompaction])); + const expected = true; + const actual = result.blocks.some((b) => b.type === 'compaction'); + expect(actual).toBe(expected); + }); + + it('compaction block carries the summary text', async () => { + const stream = new MessageStream(); + const result = await stream.process(makeStream([startCompaction, deltaCompaction('Session summary'), stopCompaction])); + const block = result.blocks.find((b) => b.type === 'compaction') as { type: 'compaction'; content: string } | undefined; + const expected = 'Session summary'; + expect(block?.content).toBe(expected); + }); + + it('emits compaction_complete with the summary text', async () => { + const stream = new MessageStream(); + let emitted: string | undefined; + stream.on('compaction_complete', (summary) => { + emitted = summary; + }); + await stream.process(makeStream([startCompaction, deltaCompaction('Session summary'), stopCompaction])); + const expected = 'Session summary'; + expect(emitted).toBe(expected); + }); +});