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/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..c85b698 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 type { IMessageStreamer } from './MessageStreamer'; import { calculateCost, getContextWindow } from './pricing'; -import { buildRequestParams } from './RequestBuilder'; +import { buildRequestParams, type RequestBuilderOptions } from './RequestBuilder'; 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 }); @@ -57,9 +59,10 @@ 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); + 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..72ee9ad 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 { customFetch } from './http/customFetch'; import { TokenRefreshingAnthropic } from './http/TokenRefreshingAnthropic'; +import { AnthropicMessageStreamer } from './MessageStreamer'; 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..e96a566 --- /dev/null +++ b/packages/claude-sdk/src/private/MessageStreamer.ts @@ -0,0 +1,20 @@ +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..df95a34 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 { AnthropicBetaFlags, AnyToolDefinition } 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/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 { + 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 readonly messages: SdkMessage[] = []; + + public constructor() { + super(); + this.consumerPort = new MessageChannel().port2; + } + + public send(msg: SdkMessage): void { + this.messages.push(msg); + } + public close(): void {} +} + +class FakeAgentChannelFactory extends IAgentChannelFactory { + public channel!: FakeAgentChannel; + + public create(_onMessage: (msg: ConsumerMessage) => void): IAgentChannel { + this.channel = new FakeAgentChannel(); + return this.channel; + } +} + +// --------------------------------------------------------------------------- +// 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'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// 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 a43efe9..0f7ddb2 100644 --- a/packages/claude-sdk/test/RequestBuilder.spec.ts +++ b/packages/claude-sdk/test/RequestBuilder.spec.ts @@ -2,9 +2,10 @@ 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 { AnyToolDefinition, RunAgentQuery } from '../src/public/types.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,29 @@ 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); + }); +});