From 0a80af56cef416e787a098763fefdb1d4b250dac Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Tue, 7 Apr 2026 01:28:09 +1000 Subject: [PATCH 1/3] Guard against null compaction delta content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API can send a compaction block where the delta content is null rather than a string. Two problems resulted from this: 1. `'' += null` coerces to the string "null", so the summary stored in the accumulator was literally the word "null" rather than empty. 2. The stop handler always pushed the compaction block to completed and emitted compaction_complete regardless of whether any real content arrived — causing the conversation history to be cleared even though no valid summary existed. Fix: coerce null deltas to empty string, and skip the push/emit at stop time if the accumulated content is empty. The compaction_start event still fires so the UI can show that compaction was attempted, but the history is not wiped and no garbage summary is reported. --- packages/claude-sdk/src/private/MessageStream.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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': From 4596f10f337b66a94cdf6be925a2d38c531e880f Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Tue, 7 Apr 2026 01:45:05 +1000 Subject: [PATCH 2/3] Fix compaction test helpers to match real SDK types Both test files had compaction message helpers built with wrong assumptions: - role was 'user' but compaction blocks come back on assistant messages - field was 'summary' (non-existent) instead of 'content: string | null' - included 'llm_identifier' which is not in BetaCompactionBlockParam - used 'as unknown as' / 'satisfies' casts to paper over the type errors With the correct shape the casts are unnecessary. The replayHistory spec also moves the two compaction tests from the user-messages describe block to the assistant-messages describe block, where they belong. --- apps/claude-sdk-cli/src/replayHistory.ts | 12 +++---- .../claude-sdk-cli/test/replayHistory.spec.ts | 33 ++++++++++--------- packages/claude-sdk/test/Conversation.spec.ts | 6 ++-- 3 files changed, 24 insertions(+), 27 deletions(-) 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/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)[] { From beabe6d7f68ed169256a39f4de67ca173f17cb6a Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Tue, 7 Apr 2026 02:06:43 +1000 Subject: [PATCH 3/3] Add MessageStream tests for null compaction content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most critical fix — null compaction delta not wiping conversation history — had no direct test. Add a MessageStream spec that feeds the stream a compaction block with null delta content and asserts: - result.blocks does not contain a compaction block (history is safe) - compaction_complete is still emitted with the fallback message Also cover the happy path: valid content produces a block in result.blocks and emits compaction_complete with the actual summary text. --- .../claude-sdk/test/MessageStream.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/claude-sdk/test/MessageStream.spec.ts 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); + }); +});