From f57f062011dbb8bb152c9df6d5ffccbe24fa4b1b Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Thu, 9 Apr 2026 01:31:20 +1000 Subject: [PATCH 1/2] Fix systemReminder leaking into tool-result continuation calls (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systemReminder is meant to be ephemeral context for the opening call of a human turn — a nudge to the model that isn't stored in conversation history. It was instead passed on every iteration of the agent loop, including tool-result continuations. Tool-result continuations are not new user turns. They are the model's own tool-use exchange completing itself. Appending user-turn context there is semantically wrong, and it changes the request shape on every loop iteration, breaking prompt cache stability. The fix consumes systemReminder exactly once: extracted from options before the loop in AgentRun.execute(), passed into the first #getMessageStream call, then cleared. Every subsequent call in the same turn sees undefined. To make this testable without hitting the Anthropic API, AgentRun's constructor was changed to accept IMessageStreamer and IAgentChannelFactory abstractions instead of a raw Anthropic client. AnthropicAgent creates the concrete implementations and passes them in. FakeMessageStreamer in the test captures every call body, allowing direct assertion on what was and wasn't sent. --- .../claude-sdk/src/private/AgentChannel.ts | 19 ++- packages/claude-sdk/src/private/AgentRun.ts | 41 ++++-- .../claude-sdk/src/private/AnthropicAgent.ts | 11 +- .../claude-sdk/src/private/MessageStreamer.ts | 23 +++ .../claude-sdk/src/private/RequestBuilder.ts | 18 ++- packages/claude-sdk/test/AgentRun.spec.ts | 138 ++++++++++++++++++ .../claude-sdk/test/RequestBuilder.spec.ts | 41 +++++- 7 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 packages/claude-sdk/src/private/MessageStreamer.ts create mode 100644 packages/claude-sdk/test/AgentRun.spec.ts diff --git a/packages/claude-sdk/src/private/AgentChannel.ts b/packages/claude-sdk/src/private/AgentChannel.ts index c26dea1..56cafb8 100644 --- a/packages/claude-sdk/src/private/AgentChannel.ts +++ b/packages/claude-sdk/src/private/AgentChannel.ts @@ -1,11 +1,22 @@ import { MessageChannel, type MessagePort } from 'node:worker_threads'; import type { ConsumerMessage, SdkMessage } from '../public/types'; -export class AgentChannel { +export abstract class IAgentChannel { + public abstract get consumerPort(): MessagePort; + public abstract send(msg: SdkMessage): void; + public abstract close(): void; +} + +export abstract class IAgentChannelFactory { + public abstract create(onMessage: (msg: ConsumerMessage) => void): IAgentChannel; +} + +export class AgentChannel extends IAgentChannel { readonly #port: MessagePort; public readonly consumerPort: MessagePort; public constructor(onMessage: (msg: ConsumerMessage) => void) { + super(); const { port1, port2 } = new MessageChannel(); this.#port = port1; this.consumerPort = port2; @@ -20,3 +31,9 @@ export class AgentChannel { this.#port.close(); } } + +export class AgentChannelFactory extends IAgentChannelFactory { + public create(onMessage: (msg: ConsumerMessage) => void): IAgentChannel { + return new AgentChannel(onMessage); + } +} diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 970e95c..2b58c51 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -4,31 +4,32 @@ import type { Anthropic } from '@anthropic-ai/sdk'; import type { BetaCompactionBlockParam, BetaTextBlockParam, BetaThinkingBlockParam, BetaToolUseBlockParam } from '@anthropic-ai/sdk/resources/beta.mjs'; import { CacheTtl } from '../public/enums'; import type { AnyToolDefinition, ILogger, RunAgentQuery, SdkMessage } from '../public/types'; -import { AgentChannel } from './AgentChannel'; +import type { IAgentChannel, IAgentChannelFactory } from './AgentChannel'; import { ApprovalState } from './ApprovalState'; import type { ConversationStore } from './ConversationStore'; import { MessageStream } from './MessageStream'; import { calculateCost, getContextWindow } from './pricing'; -import { buildRequestParams } from './RequestBuilder'; +import { buildRequestParams, type RequestBuilderOptions } from './RequestBuilder'; +import type { IMessageStreamer } from './MessageStreamer'; import type { ContentBlock, MessageStreamResult, ToolUseResult } from './types'; export class AgentRun { - readonly #client: Anthropic; + readonly #streamer: IMessageStreamer; readonly #logger: ILogger | undefined; readonly #options: RunAgentQuery; readonly #history: ConversationStore; - readonly #channel: AgentChannel; + readonly #channel: IAgentChannel; readonly #approval: ApprovalState; readonly #abortController: AbortController; - public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationStore) { - this.#client = client; + public constructor(streamer: IMessageStreamer, channelFactory: IAgentChannelFactory, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationStore) { + this.#streamer = streamer; this.#logger = logger; this.#options = options; this.#history = history; this.#abortController = new AbortController(); this.#approval = new ApprovalState(); - this.#channel = new AgentChannel((msg) => { + this.#channel = channelFactory.create((msg) => { if (msg.type === 'cancel') { this.#abortController.abort(); } @@ -46,6 +47,7 @@ export class AgentRun { } try { + let systemReminder = this.#options.systemReminder; let emptyToolUseRetries = 0; while (!this.#approval.cancelled) { this.#logger?.debug('messages', { messages: this.#history.messages.length }); @@ -59,7 +61,8 @@ export class AgentRun { const systemPromptCount = 1 + (this.#options.systemPrompts?.length ?? 0); this.#channel.send({ type: 'query_summary', systemPrompts: systemPromptCount, userMessages, assistantMessages, thinkingBlocks }); - const stream = this.#getMessageStream(this.#history.messages); + const stream = this.#getMessageStream(this.#history.messages, systemReminder); + systemReminder = undefined; this.#logger?.info('Processing messages'); const messageStream = new MessageStream(this.#logger); @@ -138,14 +141,26 @@ export class AgentRun { } } - #getMessageStream(messages: Anthropic.Beta.Messages.BetaMessageParam[]) { - const { body, headers } = buildRequestParams(this.#options, messages); - const requestOptions = { + #getMessageStream(messages: Anthropic.Beta.Messages.BetaMessageParam[], systemReminder: string | undefined) { + const builderOptions: RequestBuilderOptions = { + model: this.#options.model, + maxTokens: this.#options.maxTokens, + thinking: this.#options.thinking, + tools: this.#options.tools, + betas: this.#options.betas, + systemPrompts: this.#options.systemPrompts, + systemReminder, + pauseAfterCompact: this.#options.pauseAfterCompact, + compactInputTokens: this.#options.compactInputTokens, + cacheTtl: this.#options.cacheTtl, + }; + const { body, headers } = buildRequestParams(builderOptions, messages); + const requestOptions: Anthropic.RequestOptions = { headers, signal: this.#abortController.signal, - } satisfies Anthropic.RequestOptions; + }; this.#logger?.info('Sending request', body); - return this.#client.beta.messages.stream(body, requestOptions); + return this.#streamer.stream(body, requestOptions); } async #handleTools(toolUses: ToolUseResult[]): Promise { diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index df2b8fe..6194ea5 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -2,13 +2,16 @@ import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; import versionJson from '@shellicar/build-version/version'; import { IAnthropicAgent } from '../public/interfaces'; import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types'; +import { AgentChannelFactory } from './AgentChannel'; import { AgentRun } from './AgentRun'; import { ConversationStore } from './ConversationStore'; +import { AnthropicMessageStreamer } from './MessageStreamer'; import { customFetch } from './http/customFetch'; import { TokenRefreshingAnthropic } from './http/TokenRefreshingAnthropic'; export class AnthropicAgent extends IAnthropicAgent { - readonly #client: TokenRefreshingAnthropic; + readonly #streamer: AnthropicMessageStreamer; + readonly #channelFactory: AgentChannelFactory; readonly #logger: ILogger | undefined; readonly #history: ConversationStore; @@ -18,17 +21,19 @@ export class AnthropicAgent extends IAnthropicAgent { const defaultHeaders = { 'user-agent': `@shellicar/claude-sdk/${versionJson.version}`, }; - this.#client = new TokenRefreshingAnthropic({ + const client = new TokenRefreshingAnthropic({ authToken: options.authToken, fetch: customFetch(options.logger), logger: options.logger, defaultHeaders, }); + this.#streamer = new AnthropicMessageStreamer(client); + this.#channelFactory = new AgentChannelFactory(); this.#history = new ConversationStore(options.historyFile); } public runAgent(options: RunAgentQuery): RunAgentResult { - const run = new AgentRun(this.#client, this.#logger, options, this.#history); + const run = new AgentRun(this.#streamer, this.#channelFactory, this.#logger, options, this.#history); return { port: run.port, done: run.execute() }; } diff --git a/packages/claude-sdk/src/private/MessageStreamer.ts b/packages/claude-sdk/src/private/MessageStreamer.ts new file mode 100644 index 0000000..8a7ca05 --- /dev/null +++ b/packages/claude-sdk/src/private/MessageStreamer.ts @@ -0,0 +1,23 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; +import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta.mjs'; + +export abstract class IMessageStreamer { + public abstract stream( + body: BetaMessageStreamParams, + options: Anthropic.RequestOptions, + ): AsyncIterable; +} + +export class AnthropicMessageStreamer extends IMessageStreamer { + readonly #client: Anthropic; + + public constructor(client: Anthropic) { + super(); + this.#client = client; + } + + public stream(body: BetaMessageStreamParams, options: Anthropic.RequestOptions): AsyncIterable { + return this.#client.beta.messages.stream(body, options); + } +} diff --git a/packages/claude-sdk/src/private/RequestBuilder.ts b/packages/claude-sdk/src/private/RequestBuilder.ts index 12af878..a1a3d35 100644 --- a/packages/claude-sdk/src/private/RequestBuilder.ts +++ b/packages/claude-sdk/src/private/RequestBuilder.ts @@ -1,8 +1,9 @@ import type { Anthropic } from '@anthropic-ai/sdk'; import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; import type { BetaCacheControlEphemeral, BetaClearThinking20251015Edit, BetaClearToolUses20250919Edit, BetaCompact20260112Edit, BetaContentBlockParam, BetaContextManagementConfig, BetaTextBlockParam, BetaToolUnion } from '@anthropic-ai/sdk/resources/beta.mjs'; +import type { Model } from '@anthropic-ai/sdk/resources/messages'; import { AnthropicBeta, CacheTtl } from '../public/enums'; -import type { RunAgentQuery } from '../public/types'; +import type { AnyToolDefinition, AnthropicBetaFlags } from '../public/types'; import { AGENT_SDK_PREFIX } from './consts'; export type RequestParams = { @@ -10,6 +11,19 @@ export type RequestParams = { headers: { 'anthropic-beta': string }; }; +export type RequestBuilderOptions = { + model: Model; + thinking?: boolean; + maxTokens: number; + systemPrompts?: string[]; + systemReminder?: string; + tools: AnyToolDefinition[]; + betas?: AnthropicBetaFlags; + pauseAfterCompact?: boolean; + compactInputTokens?: number; + cacheTtl?: CacheTtl; +}; + function addCacheControlToLastBlock(msg: Anthropic.Beta.Messages.BetaMessageParam, cacheTtl: CacheTtl | undefined): Anthropic.Beta.Messages.BetaMessageParam { const cache_control = { type: 'ephemeral' as const, ttl: cacheTtl }; @@ -58,7 +72,7 @@ function withCachedLastUserMessage(messages: Anthropic.Beta.Messages.BetaMessage * AgentRun calls this and adds the AbortSignal before passing to the client, * since the signal is tied to AgentRun's abort lifecycle. */ -export function buildRequestParams(options: RunAgentQuery, messages: Anthropic.Beta.Messages.BetaMessageParam[]): RequestParams { +export function buildRequestParams(options: RequestBuilderOptions, messages: Anthropic.Beta.Messages.BetaMessageParam[]): RequestParams { const tools: BetaToolUnion[] = options.tools.map( (t) => ({ diff --git a/packages/claude-sdk/test/AgentRun.spec.ts b/packages/claude-sdk/test/AgentRun.spec.ts new file mode 100644 index 0000000..5d7edfd --- /dev/null +++ b/packages/claude-sdk/test/AgentRun.spec.ts @@ -0,0 +1,138 @@ +import { MessageChannel, type MessagePort } from 'node:worker_threads'; +import type { Anthropic } from '@anthropic-ai/sdk'; +import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; +import type { BetaContentBlockParam, BetaMessageParam, BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta.mjs'; +import { describe, expect, it } from 'vitest'; +import { IAgentChannel, IAgentChannelFactory } from '../src/private/AgentChannel.js'; +import { AgentRun } from '../src/private/AgentRun.js'; +import { ConversationStore } from '../src/private/ConversationStore.js'; +import { IMessageStreamer } from '../src/private/MessageStreamer.js'; +import type { ConsumerMessage, RunAgentQuery, SdkMessage } from '../src/public/types.js'; + +// --------------------------------------------------------------------------- +// Stream helpers +// --------------------------------------------------------------------------- + +async function* makeEndTurnStream(text: string): AsyncIterable { + yield { type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } } as BetaRawMessageStreamEvent; + yield { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } as BetaRawMessageStreamEvent; + yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } } as BetaRawMessageStreamEvent; + yield { type: 'content_block_stop', index: 0 } as BetaRawMessageStreamEvent; + yield { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 5 } } as BetaRawMessageStreamEvent; + yield { type: 'message_stop' } as BetaRawMessageStreamEvent; +} + +async function* makeToolUseStream(toolId: string, toolName: string): AsyncIterable { + yield { type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } } as BetaRawMessageStreamEvent; + yield { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: toolId, name: toolName, input: {} } } as BetaRawMessageStreamEvent; + yield { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{}' } } as BetaRawMessageStreamEvent; + yield { type: 'content_block_stop', index: 0 } as BetaRawMessageStreamEvent; + yield { type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null }, usage: { output_tokens: 5 } } as BetaRawMessageStreamEvent; + yield { type: 'message_stop' } as BetaRawMessageStreamEvent; +} + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +class FakeMessageStreamer extends IMessageStreamer { + public readonly calls: BetaMessageStreamParams[] = []; + readonly #responses: Array>; + + public constructor(responses: Array>) { + super(); + this.#responses = [...responses]; + } + + public stream(body: BetaMessageStreamParams, _options: Anthropic.RequestOptions): AsyncIterable { + this.calls.push(body); + const next = this.#responses.shift(); + if (next == null) { + throw new Error('FakeMessageStreamer: no more scripted responses'); + } + return next; + } +} + +class FakeAgentChannel extends IAgentChannel { + public readonly consumerPort: MessagePort; + + public constructor() { + super(); + this.consumerPort = new MessageChannel().port2; + } + + public send(_msg: SdkMessage): void { } + public close(): void { } +} + +class FakeAgentChannelFactory extends IAgentChannelFactory { + public create(_onMessage: (msg: ConsumerMessage) => void): IAgentChannel { + return new FakeAgentChannel(); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeOptions(overrides: Partial = {}): RunAgentQuery { + return { + model: 'claude-opus-4-5' as RunAgentQuery['model'], + maxTokens: 1024, + messages: ['hello'], + tools: [], + ...overrides, + }; +} + +const getContentBlock = (msg: BetaMessageParam[]): BetaContentBlockParam | null => { + const last = msg.at(-1)?.content.at(-1); + if (typeof last === 'object') { + return last; + } + return null; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AgentRun — systemReminder', () => { + describe('single-call turn', () => { + it('injects reminder as last block of the user message', async () => { + const streamer = new FakeMessageStreamer([makeEndTurnStream('done')]); + const run = new AgentRun(streamer, new FakeAgentChannelFactory(), undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); + await run.execute(); + + const actual = getContentBlock(streamer.calls[0]?.messages); + const expected = { type: 'text', text: '\nstay focused\n' }; + expect(actual).toEqual(expected); + }); + }); + + describe('tool-use continuation', () => { + it('makes exactly two API calls', async () => { + const streamer = new FakeMessageStreamer([ + makeToolUseStream('tu_1', 'SomeTool'), + makeEndTurnStream('done'), + ]); + const run = new AgentRun(streamer, new FakeAgentChannelFactory(), undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); + await run.execute(); + + expect(streamer.calls).toHaveLength(2); + }); + + it('second call ends with a tool_result block', async () => { + const streamer = new FakeMessageStreamer([ + makeToolUseStream('tu_1', 'SomeTool'), + makeEndTurnStream('done'), + ]); + const run = new AgentRun(streamer, new FakeAgentChannelFactory(), undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); + await run.execute(); + + const actual = getContentBlock(streamer.calls[1].messages)?.type; + expect(actual).toBe('tool_result'); + }); + }); +}); diff --git a/packages/claude-sdk/test/RequestBuilder.spec.ts b/packages/claude-sdk/test/RequestBuilder.spec.ts index a43efe9..c64e489 100644 --- a/packages/claude-sdk/test/RequestBuilder.spec.ts +++ b/packages/claude-sdk/test/RequestBuilder.spec.ts @@ -4,7 +4,8 @@ import type { BetaMessageParam } from '../src/index.js'; import { AGENT_SDK_PREFIX } from '../src/private/consts.js'; import { buildRequestParams } from '../src/private/RequestBuilder.js'; import { AnthropicBeta, CacheTtl } from '../src/public/enums.js'; -import type { AnyToolDefinition, RunAgentQuery } from '../src/public/types.js'; +import type { RequestBuilderOptions } from '../src/private/RequestBuilder.js'; +import type { AnyToolDefinition } from '../src/public/types.js'; // --------------------------------------------------------------------------- // Helpers @@ -27,11 +28,10 @@ function makeTool(name: string, jsonSchema: Record = {}): AnyTo }; } -function makeOptions(overrides: Partial = {}): RunAgentQuery { +function makeOptions(overrides: Partial = {}): RequestBuilderOptions { return { - model: 'claude-opus-4-5' as RunAgentQuery['model'], + model: 'claude-opus-4-5' as RequestBuilderOptions['model'], maxTokens: 1024, - messages: [], tools: [], ...overrides, }; @@ -66,7 +66,7 @@ const noMessages: Anthropic.Beta.Messages.BetaMessageParam[] = []; describe('buildRequestParams — base', () => { it('body.model matches options.model', () => { const expected = 'claude-opus-4-5'; - const actual = buildRequestParams(makeOptions({ model: expected as RunAgentQuery['model'] }), noMessages).body.model; + const actual = buildRequestParams(makeOptions({ model: expected as RequestBuilderOptions['model'] }), noMessages).body.model; expect(actual).toBe(expected); }); @@ -403,3 +403,34 @@ describe('buildRequestParams — messages', () => { expect(actual).toBe(expected); }); }); + + +// --------------------------------------------------------------------------- +// systemReminder +// --------------------------------------------------------------------------- + +describe('buildRequestParams — systemReminder', () => { + // buildRequestParams injects systemReminder unconditionally when provided. + // AgentRun is responsible for only passing it on the first call of a turn + // (one-shot: set from options, cleared after the first #getMessageStream call). + + it('injects systemReminder as the last content block of the last user message', () => { + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ]; + const { body } = buildRequestParams(makeOptions({ systemReminder: 'stay focused' }), messages); + const actual = (body.messages.at(-1)?.content as { type: string; text: string }[]).at(-1); + const expected = { type: 'text', text: '\nstay focused\n' }; + expect(actual).toEqual(expected); + }); + + it('last content block is unchanged when systemReminder is not set', () => { + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ]; + const { body } = buildRequestParams(makeOptions({ systemReminder: undefined }), messages); + const actual = (body.messages.at(-1)?.content as { type: string; text: string; cache_control?: unknown }[]).at(-1); + const expected = { type: 'text', text: 'hello', cache_control: { type: 'ephemeral', ttl: CacheTtl.OneHour } }; + expect(actual).toEqual(expected); + }); +}); From 9c9a97f78d5c60274510fb2c0733b59010aa6280 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Thu, 9 Apr 2026 01:56:30 +1000 Subject: [PATCH 2/2] fix display: systemReminder was re-shown on every query, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentMessageHandler stored gitDelta at construction time and rendered it on every query_summary message. The field was set once, never cleared, so every loop iteration — including tool-result continuations — showed the git delta header even though the reminder was only meant to be visible for the first API call of a turn. Fix: thread systemReminder through the query_summary SDK message itself. AgentRun already has a local variable that holds the reminder for the first call and is cleared to undefined after; that same value is now included in the channel send before the clear. AgentMessageHandler reads msg.systemReminder, so it only renders when the message actually carries the value — which is exactly once per turn. Removes gitDelta from AgentMessageHandlerOptions entirely; the field only ever existed to feed this display path, and that role is now filled by the message payload. Tests added: - AgentRun: first query_summary carries systemReminder, second does not - AgentMessageHandler: appends reminder when set, omits it when absent Closes #227 --- .../claude-sdk-cli/src/AgentMessageHandler.ts | 5 +- apps/claude-sdk-cli/src/runAgent.ts | 2 +- .../test/AgentMessageHandler.spec.ts | 16 +++++++ packages/claude-sdk/src/private/AgentRun.ts | 4 +- .../claude-sdk/src/private/AnthropicAgent.ts | 2 +- .../claude-sdk/src/private/MessageStreamer.ts | 5 +- .../claude-sdk/src/private/RequestBuilder.ts | 2 +- packages/claude-sdk/src/public/types.ts | 2 +- packages/claude-sdk/test/AgentRun.spec.ts | 48 ++++++++++++++----- .../claude-sdk/test/RequestBuilder.spec.ts | 11 ++--- 10 files changed, 64 insertions(+), 33 deletions(-) diff --git a/apps/claude-sdk-cli/src/AgentMessageHandler.ts b/apps/claude-sdk-cli/src/AgentMessageHandler.ts index b837962..fdd0af3 100644 --- a/apps/claude-sdk-cli/src/AgentMessageHandler.ts +++ b/apps/claude-sdk-cli/src/AgentMessageHandler.ts @@ -77,7 +77,6 @@ export interface AgentMessageHandlerOptions { store: RefStore; tools: AnyToolDefinition[]; respond: (requestId: string, approved: boolean) => void; - gitDelta?: string; } // ---- class --------------------------------------------------------------- @@ -99,7 +98,6 @@ export class AgentMessageHandler { #store: RefStore; #tools: AnyToolDefinition[]; #respond: (requestId: string, approved: boolean) => void; - #gitDelta: string | undefined; #lastUsage: SdkMessageUsage | null = null; #usageBeforeTools: SdkMessageUsage | null = null; @@ -112,7 +110,6 @@ export class AgentMessageHandler { this.#store = opts.store; this.#tools = opts.tools; this.#respond = opts.respond; - this.#gitDelta = opts.gitDelta; } public handle(msg: SdkMessage): void { @@ -120,7 +117,7 @@ export class AgentMessageHandler { case 'query_summary': { const parts = [`${msg.systemPrompts} system`, `${msg.userMessages} user`, `${msg.assistantMessages} assistant`, ...(msg.thinkingBlocks > 0 ? [`${msg.thinkingBlocks} thinking`] : [])]; this.#layout.transitionBlock('meta'); - const deltaLine = this.#gitDelta ? `\n${this.#gitDelta}` : ''; + const deltaLine = msg.systemReminder ? `\n${msg.systemReminder}` : ''; this.#layout.appendStreaming(`\uD83E\uDD16 ${this.#model}\n${parts.join(' \u00b7 ')}${deltaLine}`); break; } diff --git a/apps/claude-sdk-cli/src/runAgent.ts b/apps/claude-sdk-cli/src/runAgent.ts index ea2a690..f50814f 100644 --- a/apps/claude-sdk-cli/src/runAgent.ts +++ b/apps/claude-sdk-cli/src/runAgent.ts @@ -71,7 +71,7 @@ export async function runAgent(agent: IAnthropicAgent, prompt: string, layout: A port.postMessage({ type: 'tool_approval_response', requestId, approved }); }; - const handler = new AgentMessageHandler(layout, logger, { model, cacheTtl, cwd, store, tools, respond, gitDelta }); + const handler = new AgentMessageHandler(layout, logger, { model, cacheTtl, cwd, store, tools, respond }); port.on('message', (msg: SdkMessage) => handler.handle(msg)); diff --git a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts index c300bc7..4243924 100644 --- a/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts +++ b/apps/claude-sdk-cli/test/AgentMessageHandler.spec.ts @@ -81,6 +81,22 @@ describe('AgentMessageHandler — query_summary', () => { const actual = (vi.mocked(layout.appendStreaming).mock.calls[0]?.[0] ?? '').includes('thinking'); expect(actual).toBe(expected); }); + + it('appends systemReminder on a new line when set', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'query_summary', systemPrompts: 1, userMessages: 1, assistantMessages: 1, thinkingBlocks: 0, systemReminder: '[git delta] untracked: +1' }); + const actual = vi.mocked(layout.appendStreaming).mock.calls[0]?.[0]; + const expected = '\uD83E\uDD16 claude-test\n1 system \u00b7 1 user \u00b7 1 assistant\n[git delta] untracked: +1'; + expect(actual).toBe(expected); + }); + + it('streamed line ends at stats when systemReminder is absent', () => { + const layout = makeLayout(); + makeHandler(layout).handle({ type: 'query_summary', systemPrompts: 1, userMessages: 1, assistantMessages: 1, thinkingBlocks: 0 }); + const actual = vi.mocked(layout.appendStreaming).mock.calls[0]?.[0]; + const expected = '\uD83E\uDD16 claude-test\n1 system \u00b7 1 user \u00b7 1 assistant'; + expect(actual).toBe(expected); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 2b58c51..c85b698 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -8,9 +8,9 @@ import type { IAgentChannel, IAgentChannelFactory } from './AgentChannel'; import { ApprovalState } from './ApprovalState'; import type { ConversationStore } from './ConversationStore'; import { MessageStream } from './MessageStream'; +import type { IMessageStreamer } from './MessageStreamer'; import { calculateCost, getContextWindow } from './pricing'; import { buildRequestParams, type RequestBuilderOptions } from './RequestBuilder'; -import type { IMessageStreamer } from './MessageStreamer'; import type { ContentBlock, MessageStreamResult, ToolUseResult } from './types'; export class AgentRun { @@ -59,7 +59,7 @@ export class AgentRun { .flatMap((m) => (Array.isArray(m.content) ? m.content : [])) .filter((b) => b.type === 'thinking').length; const systemPromptCount = 1 + (this.#options.systemPrompts?.length ?? 0); - this.#channel.send({ type: 'query_summary', systemPrompts: systemPromptCount, userMessages, assistantMessages, thinkingBlocks }); + this.#channel.send({ type: 'query_summary', systemPrompts: systemPromptCount, userMessages, assistantMessages, thinkingBlocks, systemReminder }); const stream = this.#getMessageStream(this.#history.messages, systemReminder); systemReminder = undefined; diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index 6194ea5..72ee9ad 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -5,9 +5,9 @@ import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } fr import { AgentChannelFactory } from './AgentChannel'; import { AgentRun } from './AgentRun'; import { ConversationStore } from './ConversationStore'; -import { AnthropicMessageStreamer } from './MessageStreamer'; import { customFetch } from './http/customFetch'; import { TokenRefreshingAnthropic } from './http/TokenRefreshingAnthropic'; +import { AnthropicMessageStreamer } from './MessageStreamer'; export class AnthropicAgent extends IAnthropicAgent { readonly #streamer: AnthropicMessageStreamer; diff --git a/packages/claude-sdk/src/private/MessageStreamer.ts b/packages/claude-sdk/src/private/MessageStreamer.ts index 8a7ca05..e96a566 100644 --- a/packages/claude-sdk/src/private/MessageStreamer.ts +++ b/packages/claude-sdk/src/private/MessageStreamer.ts @@ -3,10 +3,7 @@ import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/m import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta.mjs'; export abstract class IMessageStreamer { - public abstract stream( - body: BetaMessageStreamParams, - options: Anthropic.RequestOptions, - ): AsyncIterable; + public abstract stream(body: BetaMessageStreamParams, options: Anthropic.RequestOptions): AsyncIterable; } export class AnthropicMessageStreamer extends IMessageStreamer { diff --git a/packages/claude-sdk/src/private/RequestBuilder.ts b/packages/claude-sdk/src/private/RequestBuilder.ts index a1a3d35..df95a34 100644 --- a/packages/claude-sdk/src/private/RequestBuilder.ts +++ b/packages/claude-sdk/src/private/RequestBuilder.ts @@ -3,7 +3,7 @@ import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/m import type { BetaCacheControlEphemeral, BetaClearThinking20251015Edit, BetaClearToolUses20250919Edit, BetaCompact20260112Edit, BetaContentBlockParam, BetaContextManagementConfig, BetaTextBlockParam, BetaToolUnion } from '@anthropic-ai/sdk/resources/beta.mjs'; import type { Model } from '@anthropic-ai/sdk/resources/messages'; import { AnthropicBeta, CacheTtl } from '../public/enums'; -import type { AnyToolDefinition, AnthropicBetaFlags } from '../public/types'; +import type { AnthropicBetaFlags, AnyToolDefinition } from '../public/types'; import { AGENT_SDK_PREFIX } from './consts'; export type RequestParams = { diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index 8e3cc28..00be736 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -55,7 +55,7 @@ export type SdkToolError = { type: 'tool_error'; name: string; input: Record void): IAgentChannel { - return new FakeAgentChannel(); + this.channel = new FakeAgentChannel(); + return this.channel; } } @@ -113,10 +119,7 @@ describe('AgentRun — systemReminder', () => { describe('tool-use continuation', () => { it('makes exactly two API calls', async () => { - const streamer = new FakeMessageStreamer([ - makeToolUseStream('tu_1', 'SomeTool'), - makeEndTurnStream('done'), - ]); + const streamer = new FakeMessageStreamer([makeToolUseStream('tu_1', 'SomeTool'), makeEndTurnStream('done')]); const run = new AgentRun(streamer, new FakeAgentChannelFactory(), undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); await run.execute(); @@ -124,10 +127,7 @@ describe('AgentRun — systemReminder', () => { }); it('second call ends with a tool_result block', async () => { - const streamer = new FakeMessageStreamer([ - makeToolUseStream('tu_1', 'SomeTool'), - makeEndTurnStream('done'), - ]); + const streamer = new FakeMessageStreamer([makeToolUseStream('tu_1', 'SomeTool'), makeEndTurnStream('done')]); const run = new AgentRun(streamer, new FakeAgentChannelFactory(), undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); await run.execute(); @@ -136,3 +136,29 @@ describe('AgentRun — systemReminder', () => { }); }); }); + +// --------------------------------------------------------------------------- +// query_summary channel messages +// --------------------------------------------------------------------------- + +describe('AgentRun — systemReminder in query_summary', () => { + it('first query_summary carries systemReminder', async () => { + const streamer = new FakeMessageStreamer([makeEndTurnStream('done')]); + const factory = new FakeAgentChannelFactory(); + const run = new AgentRun(streamer, factory, undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); + await run.execute(); + + const summaries = factory.channel.messages.filter((m): m is Extract => m.type === 'query_summary'); + expect(summaries[0]?.systemReminder).toBe('stay focused'); + }); + + it('second query_summary does not carry systemReminder', async () => { + const streamer = new FakeMessageStreamer([makeToolUseStream('tu_1', 'SomeTool'), makeEndTurnStream('done')]); + const factory = new FakeAgentChannelFactory(); + const run = new AgentRun(streamer, factory, undefined, makeOptions({ systemReminder: 'stay focused' }), new ConversationStore()); + await run.execute(); + + const summaries = factory.channel.messages.filter((m): m is Extract => m.type === 'query_summary'); + expect(summaries[1]?.systemReminder).toBeUndefined(); + }); +}); diff --git a/packages/claude-sdk/test/RequestBuilder.spec.ts b/packages/claude-sdk/test/RequestBuilder.spec.ts index c64e489..0f7ddb2 100644 --- a/packages/claude-sdk/test/RequestBuilder.spec.ts +++ b/packages/claude-sdk/test/RequestBuilder.spec.ts @@ -2,9 +2,9 @@ import type { Anthropic } from '@anthropic-ai/sdk'; import { describe, expect, it } from 'vitest'; import type { BetaMessageParam } from '../src/index.js'; import { AGENT_SDK_PREFIX } from '../src/private/consts.js'; +import type { RequestBuilderOptions } from '../src/private/RequestBuilder.js'; import { buildRequestParams } from '../src/private/RequestBuilder.js'; import { AnthropicBeta, CacheTtl } from '../src/public/enums.js'; -import type { RequestBuilderOptions } from '../src/private/RequestBuilder.js'; import type { AnyToolDefinition } from '../src/public/types.js'; // --------------------------------------------------------------------------- @@ -404,7 +404,6 @@ describe('buildRequestParams — messages', () => { }); }); - // --------------------------------------------------------------------------- // systemReminder // --------------------------------------------------------------------------- @@ -415,9 +414,7 @@ describe('buildRequestParams — systemReminder', () => { // (one-shot: set from options, cleared after the first #getMessageStream call). it('injects systemReminder as the last content block of the last user message', () => { - const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - ]; + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }]; const { body } = buildRequestParams(makeOptions({ systemReminder: 'stay focused' }), messages); const actual = (body.messages.at(-1)?.content as { type: string; text: string }[]).at(-1); const expected = { type: 'text', text: '\nstay focused\n' }; @@ -425,9 +422,7 @@ describe('buildRequestParams — systemReminder', () => { }); it('last content block is unchanged when systemReminder is not set', () => { - const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - ]; + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }]; const { body } = buildRequestParams(makeOptions({ systemReminder: undefined }), messages); const actual = (body.messages.at(-1)?.content as { type: string; text: string; cache_control?: unknown }[]).at(-1); const expected = { type: 'text', text: 'hello', cache_control: { type: 'ephemeral', ttl: CacheTtl.OneHour } };