diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 0d849123a..036360223 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed `Agent` API types so `systemPrompt` is now a list of prompt strings, requiring callers to pass and update system prompts via string arrays + +### Changed + +- Removed automatic project-context injection into each model call from loop logic + +### Removed + +- Removed the `projectPrompt` field from agent state/context and the `setProjectPrompt` mutator ## [14.6.2] - 2026-05-03 diff --git a/packages/agent/README.md b/packages/agent/README.md index 7c3fc79da..49d2afda0 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -16,7 +16,7 @@ import { getModel } from "@oh-my-pi/pi-ai"; const agent = new Agent({ initialState: { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], model: getModel("anthropic", "claude-sonnet-4-20250514"), }, }); @@ -132,7 +132,7 @@ The last message in context must be `user` or `toolResult` (not `assistant`). const agent = new Agent({ // Initial state initialState: { - systemPrompt: string, + systemPrompt: string[], model: Model, thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", tools: AgentTool[], @@ -163,7 +163,7 @@ const agent = new Agent({ ```typescript interface AgentState { - systemPrompt: string; + systemPrompt: string[]; model: Model; thinkingLevel: ThinkingLevel; tools: AgentTool[]; @@ -348,7 +348,7 @@ For direct control without the Agent class: import { agentLoop, agentLoopContinue } from "@oh-my-pi/pi-agent"; const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [], tools: [], }; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 202758668..055698a76 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -206,7 +206,7 @@ interface CursorToolResultEntry { export class Agent { #state: AgentState = { - systemPrompt: "", + systemPrompt: [], model: getBundledModel("google", "gemini-2.5-flash-lite-preview-06-17"), thinkingLevel: undefined, tools: [], @@ -453,7 +453,7 @@ export class Agent { } // State mutators - setSystemPrompt(v: string) { + setSystemPrompt(v: string[]) { this.#state.systemPrompt = v; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 63c414387..92ce50de0 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -191,7 +191,7 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag * Agent state containing all configuration and conversation data. */ export interface AgentState { - systemPrompt: string; + systemPrompt: string[]; model: Model; thinkingLevel?: Effort; tools: AgentTool[]; @@ -283,7 +283,7 @@ export interface AgentTool[]; } diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index b81838b38..8a79003ed 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -48,7 +48,7 @@ function identityConverter(messages: AgentMessage[]): Message[] { describe("agentLoop with AgentMessage", () => { it("should emit events with AgentMessage types", async () => { const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [], tools: [], }; @@ -95,7 +95,7 @@ describe("agentLoop with AgentMessage", () => { it("emits an aborted assistant message when cancellation happens before provider events", async () => { const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [], tools: [], }; @@ -139,7 +139,7 @@ describe("agentLoop with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [notification as unknown as AgentMessage], // Custom message in context tools: [], }; @@ -181,7 +181,7 @@ describe("agentLoop with AgentMessage", () => { it("should apply transformContext before convertToLlm", async () => { const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [ createUserMessage("old message 1"), createAssistantMessage([{ type: "text", text: "old response 1" }]), @@ -253,7 +253,7 @@ describe("agentLoop with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "", + systemPrompt: [""], messages: [], tools: [tool], }; @@ -323,7 +323,7 @@ describe("agentLoop with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "", + systemPrompt: [""], messages: [], tools: [tool], }; @@ -395,7 +395,7 @@ describe("agentLoop with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "", + systemPrompt: [""], messages: [], tools: [tool], }; @@ -490,7 +490,7 @@ describe("agentLoop with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "", + systemPrompt: [""], messages: [], tools: [tool], }; @@ -559,7 +559,7 @@ describe("agentLoop with AgentMessage", () => { it("emits an explicit warning toolResult when assistant aborts after issuing tool calls", async () => { const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [], tools: [], }; @@ -627,7 +627,7 @@ describe("agentLoop with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "", + systemPrompt: [""], messages: [], tools: [tool], }; @@ -752,7 +752,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () = activeTools = [alphaTool]; const context: AgentContext = { - systemPrompt: activeSystemPrompt, + systemPrompt: [activeSystemPrompt], messages: [], tools: activeTools, }; @@ -761,7 +761,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () = model: createModel(), convertToLlm: identityConverter, syncContextBeforeModelCall: async currentContext => { - currentContext.systemPrompt = activeSystemPrompt; + currentContext.systemPrompt = [activeSystemPrompt]; currentContext.tools = activeTools; }, }; @@ -784,16 +784,16 @@ it("refreshes tools and system prompt between same-turn model calls", async () = } expect(callContexts).toHaveLength(2); - expect(callContexts[0]?.systemPrompt).toBe("prompt-one"); + expect(callContexts[0]?.systemPrompt).toEqual(["prompt-one"]); expect(callContexts[0]?.tools?.map(tool => tool.name)).toEqual(["alpha"]); - expect(callContexts[1]?.systemPrompt).toBe("prompt-two"); + expect(callContexts[1]?.systemPrompt).toEqual(["prompt-two"]); expect(callContexts[1]?.tools?.map(tool => tool.name)).toEqual(["alpha", "beta"]); }); describe("agentLoopContinue with AgentMessage", () => { it("should throw when context has no messages", () => { const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [], tools: [], }; @@ -810,7 +810,7 @@ describe("agentLoopContinue with AgentMessage", () => { const userMessage: AgentMessage = createUserMessage("Hello"); const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [userMessage], tools: [], }; @@ -863,7 +863,7 @@ describe("agentLoopContinue with AgentMessage", () => { }; const context: AgentContext = { - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], messages: [hookMessage as unknown as AgentMessage], tools: [], }; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index ecd9d5ce1..4172c605e 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -12,7 +12,7 @@ describe("Agent", () => { const agent = new Agent(); expect(agent.state).toBeDefined(); - expect(agent.state.systemPrompt).toBe(""); + expect(agent.state.systemPrompt).toEqual([]); expect(agent.state.model).toBeDefined(); expect(agent.state.thinkingLevel).toBeUndefined(); expect(agent.state.tools).toEqual([]); @@ -27,13 +27,13 @@ describe("Agent", () => { const customModel = getBundledModel("openai", "gpt-4o-mini"); const agent = new Agent({ initialState: { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], model: customModel, thinkingLevel: ThinkingLevel.Low, }, }); - expect(agent.state.systemPrompt).toBe("You are a helpful assistant."); + expect(agent.state.systemPrompt).toEqual(["You are a helpful assistant."]); expect(agent.state.model).toBe(customModel); expect(agent.state.thinkingLevel).toBe(ThinkingLevel.Low); }); @@ -50,13 +50,13 @@ describe("Agent", () => { expect(eventCount).toBe(0); // State mutators don't emit events - agent.setSystemPrompt("Test prompt"); + agent.setSystemPrompt(["Test prompt"]); expect(eventCount).toBe(0); - expect(agent.state.systemPrompt).toBe("Test prompt"); + expect(agent.state.systemPrompt).toEqual(["Test prompt"]); // Unsubscribe should work unsubscribe(); - agent.setSystemPrompt("Another prompt"); + agent.setSystemPrompt(["Another prompt"]); expect(eventCount).toBe(0); // Should not increase }); @@ -64,8 +64,8 @@ describe("Agent", () => { const agent = new Agent(); // Test setSystemPrompt - agent.setSystemPrompt("Custom prompt"); - expect(agent.state.systemPrompt).toBe("Custom prompt"); + agent.setSystemPrompt(["Custom prompt"]); + expect(agent.state.systemPrompt).toEqual(["Custom prompt"]); // Test setModel const newModel = getBundledModel("google", "gemini-2.5-flash"); @@ -229,13 +229,13 @@ describe("Agent", () => { const agent = new Agent({ initialState: { model: getBundledModel("openai", "gpt-4o-mini"), - systemPrompt: "prompt-one", + systemPrompt: ["prompt-one"], tools: [alphaTool], messages: [], }, streamFn: (_model, context) => { callContexts.push({ - systemPrompt: context.systemPrompt ?? "", + systemPrompt: context.systemPrompt?.join("\n\n") ?? "", toolNames: (context.tools ?? []).map(tool => tool.name), }); const stream = new MockAssistantStream(); @@ -249,7 +249,7 @@ describe("Agent", () => { const unsubscribe = agent.subscribe(event => { if (event.type === "message_end" && event.message.role === "toolResult") { - agent.setSystemPrompt("prompt-two"); + agent.setSystemPrompt(["prompt-two"]); agent.setTools([alphaTool, betaTool]); } }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c9267e930..1f3f54171 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,23 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed `Context.systemPrompt` from a string to `string[]`, so callers must now pass an array of prompts instead of a single string +- Changed behavior will throw at runtime for non-array system prompts because request builders now normalize system prompts as an array + +### Added + +- Added support for multiple system prompts by changing `Context.systemPrompt` to an ordered string array and preserving provider-appropriate instruction precedence + +### Changed + +- Changed request builders for Anthropic, OpenAI, Bedrock, Azure, Cursor, Google, and Ollama to propagate every non-empty system prompt entry without demoting durable instructions into ordinary conversation turns + +### Fixed + +- Filtered out empty normalized system prompts so blank entries are no longer sent to providers +- Removed blank system prompt strings from provider payloads to avoid unnecessary empty instruction messages ## [14.6.6] - 2026-05-04 diff --git a/packages/ai/README.md b/packages/ai/README.md index 04c49f13c..1f3836918 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -107,7 +107,7 @@ const tools: Tool[] = [ // Build a conversation context (easily serializable and transferable between models) const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "What time is it?" }], tools, }; @@ -873,7 +873,7 @@ import { Context, getModel, complete } from "@oh-my-pi/pi-ai"; // Create and use a context const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "What is TypeScript?" }], }; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 42bbcc671..4a3dac8a9 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -464,13 +464,14 @@ function supportsThinkingSignature(model: Model<"bedrock-converse-stream">): boo } function buildSystemPrompt( - systemPrompt: string | undefined, + systemPrompt: readonly string[] | undefined, model: Model<"bedrock-converse-stream">, cacheRetention: CacheRetention, ): SystemContentBlock[] | undefined { - if (!systemPrompt) return undefined; + const prompts = systemPrompt?.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.length > 0) ?? []; + if (prompts.length === 0) return undefined; - const blocks: SystemContentBlock[] = [{ text: systemPrompt.toWellFormed() }]; + const blocks: SystemContentBlock[] = prompts.map(prompt => ({ text: prompt })); // Add cache point for supported Claude models if (cacheRetention !== "none" && supportsPromptCaching(model)) { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 13e97eb60..44c4a2102 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -33,7 +33,13 @@ import type { ToolResultMessage, Usage, } from "../types"; -import { isAnthropicOAuthToken, isRecord, normalizeToolCallId, resolveCacheRetention } from "../utils"; +import { + isAnthropicOAuthToken, + isRecord, + normalizeSystemPrompts, + normalizeToolCallId, + resolveCacheRetention, +} from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { isFoundryEnabled } from "../utils/foundry"; @@ -1417,18 +1423,18 @@ type SystemBlockOptions = { }; export function buildAnthropicSystemBlocks( - systemPrompt: string | undefined, + systemPrompt: readonly string[] | undefined, options: SystemBlockOptions = {}, ): AnthropicSystemBlock[] | undefined { const { includeClaudeCodeInstruction = false, extraInstructions = [], billingPayload, cacheControl } = options; const blocks: AnthropicSystemBlock[] = []; - const sanitizedPrompt = systemPrompt ? systemPrompt.toWellFormed() : ""; + const sanitizedPrompts = normalizeSystemPrompts(systemPrompt); const trimmedInstructions = extraInstructions.map(instruction => instruction.trim()).filter(Boolean); - const hasBillingHeader = sanitizedPrompt.includes(CLAUDE_BILLING_HEADER_PREFIX); + const hasBillingHeader = sanitizedPrompts.some(prompt => prompt.includes(CLAUDE_BILLING_HEADER_PREFIX)); if (includeClaudeCodeInstruction && !hasBillingHeader) { const payloadSeed = billingPayload ?? { - system: sanitizedPrompt, + system: sanitizedPrompts, extraInstructions: trimmedInstructions, }; blocks.push( @@ -1441,19 +1447,19 @@ export function buildAnthropicSystemBlocks( } for (const instruction of trimmedInstructions) { - blocks.push({ - type: "text", - text: instruction, - ...(cacheControl ? { cache_control: cacheControl } : {}), - }); + blocks.push({ type: "text", text: instruction }); } - if (systemPrompt) { - blocks.push({ - type: "text", - text: sanitizedPrompt, - ...(cacheControl ? { cache_control: cacheControl } : {}), - }); + for (const systemPrompt of sanitizedPrompts) { + blocks.push({ type: "text", text: systemPrompt }); + } + + // Attach cache_control to the LAST emitted block only. Anthropic breakpoints are cumulative + // prefix cuts, so a single trailing breakpoint covers every preceding block; spreading + // cache_control across N blocks wastes slots against the 4-breakpoint cap. + const lastIndex = blocks.length - 1; + if (cacheControl && lastIndex >= 0) { + blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl }; } return blocks.length > 0 ? blocks : undefined; @@ -1921,10 +1927,11 @@ function buildParams( } const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku"); + const billingSystemPrompts = normalizeSystemPrompts(context.systemPrompt); const billingPayload = shouldInjectClaudeCodeInstruction ? { ...params, - ...(context.systemPrompt ? { system: context.systemPrompt.toWellFormed() } : {}), + ...(billingSystemPrompts.length > 0 ? { system: billingSystemPrompts } : {}), } : undefined; const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, { diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index e119e6929..6f0495629 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -18,6 +18,7 @@ import { type Tool, type ToolChoice, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; @@ -28,7 +29,7 @@ import { iterateWithIdleTimeout, } from "../utils/idle-iterator"; import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice"; -import { supportsDeveloperRole } from "./openai-responses"; +import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses"; import { appendResponsesToolResultMessages, convertResponsesAssistantMessage, @@ -273,7 +274,7 @@ function buildParams( model: deploymentName, input: messages, stream: true, - prompt_cache_key: options?.sessionId, + prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId), }; if (options?.maxTokens) { @@ -350,12 +351,12 @@ function convertMessages( const transformedMessages = transformMessages(context.messages, model, normalizeResponsesToolCallIdForTransform); const knownCallIds = new Set(); - if (context.systemPrompt) { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + if (systemPrompts.length > 0) { const role = model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model) ? "developer" : "system"; - messages.push({ - role, - content: context.systemPrompt.toWellFormed(), - }); + for (const systemPrompt of systemPrompts) { + messages.push({ role, content: systemPrompt }); + } } let msgIndex = 0; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 0605206e2..b19b12810 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -26,6 +26,7 @@ import type { ToolCall, ToolResultMessage, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { parseStreamingJson } from "../utils/json-parse"; import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; @@ -2145,12 +2146,29 @@ function findLastUserMessageIndex(messages: Message[]): number { * only an empty placeholder where historical user turns should be. * The last user message is excluded because it is sent in the action. */ +/** + * Build one Cursor system-message JSON blob per ordered system prompt. Emitting separate blobs + * (rather than a single `\n\n`-joined string) lets Cursor's blob cache hit independently per + * entry: changing only the last prompt does not invalidate earlier blob ids, so the prefix + * up to the changed prompt remains cached on the server side. + * + * When no system prompts are provided, returns a single default greeting so we never emit + * an empty `rootPromptMessagesJson` head. + */ +export function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined): string[] { + const systemPrompts = normalizeSystemPrompts(systemPrompt); + if (systemPrompts.length === 0) { + return [JSON.stringify({ role: "system", content: "You are a helpful assistant." })]; + } + return systemPrompts.map(content => JSON.stringify({ role: "system", content })); +} + function buildRootPromptMessagesJson( messages: Message[], - systemPromptId: Uint8Array, + systemPromptIds: Uint8Array[], blobStore: Map, ): Uint8Array[] { - const entries: Uint8Array[] = [systemPromptId]; + const entries: Uint8Array[] = [...systemPromptIds]; const lastUserIdx = findLastUserMessageIndex(messages); const pushJson = (obj: unknown) => { @@ -2299,12 +2317,9 @@ function buildGrpcRequest( } { const blobStore = state.blobStore; - const systemPromptJson = JSON.stringify({ - role: "system", - content: context.systemPrompt || "You are a helpful assistant.", - }); - const systemPromptBytes = new TextEncoder().encode(systemPromptJson); - const systemPromptId = storeCursorBlob(blobStore, systemPromptBytes); + const systemPromptIds = buildCursorSystemPromptJsons(context.systemPrompt).map(json => + storeCursorBlob(blobStore, new TextEncoder().encode(json)), + ); const lastMessage = context.messages[context.messages.length - 1]; const userText = @@ -2339,18 +2354,19 @@ function buildGrpcRequest( // field (not `turns[]`) to construct the actual model prompt; if we only send the // system prompt here, multi-turn conversations lose prior context and the model // sees only the current user message. - const rootPromptMessagesJson = buildRootPromptMessagesJson(context.messages, systemPromptId, blobStore); + const rootPromptMessagesJson = buildRootPromptMessagesJson(context.messages, systemPromptIds, blobStore); // Preserve cached non-history state fields (todos, file states, summaries, etc.) // when the system prompt is unchanged; otherwise start fresh. - const hasMatchingPrompt = state.conversationState?.rootPromptMessagesJson?.some(entry => - Buffer.from(entry).equals(systemPromptId), - ); + const cachedPromptHead = state.conversationState?.rootPromptMessagesJson?.slice(0, systemPromptIds.length) ?? []; + const hasMatchingPrompt = + cachedPromptHead.length === systemPromptIds.length && + systemPromptIds.every((id, idx) => Buffer.from(cachedPromptHead[idx]).equals(id)); const baseState = state.conversationState && hasMatchingPrompt ? state.conversationState : create(ConversationStateStructureSchema, { - rootPromptMessagesJson: [systemPromptId], + rootPromptMessagesJson: systemPromptIds, turns: [], todos: [], pendingToolCalls: [], diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index c05c7809d..0998ca446 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -18,6 +18,7 @@ import type { ThinkingContent, ToolCall, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector"; import { refreshAntigravityToken } from "../utils/oauth/google-antigravity"; @@ -865,8 +866,8 @@ export function buildRequest( options: GoogleGeminiCliOptions = {}, isAntigravity = false, ): CloudCodeAssistRequest { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); const contents = convertMessages(model, context); - const generationConfig: CloudCodeAssistRequest["request"]["generationConfig"] = {}; if (options.temperature !== undefined) { generationConfig.temperature = options.temperature; @@ -913,9 +914,9 @@ export function buildRequest( } // System instruction must be object with parts, not plain string - if (context.systemPrompt) { + if (systemPrompts.length > 0) { request.systemInstruction = { - parts: [{ text: context.systemPrompt.toWellFormed() }], + parts: systemPrompts.map(text => ({ text })), }; } diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index aa45e1f44..ddcb6090b 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -18,6 +18,7 @@ import type { ThinkingContent, ToolCall, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import type { GoogleThinkingLevel } from "./google-gemini-cli"; @@ -369,6 +370,7 @@ function buildParams( context: Context, options: GoogleVertexOptions = {}, ): GenerateContentParameters { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); const contents = convertMessages(model, context); const generationConfig: GoogleVertexSamplingConfig = {}; @@ -396,7 +398,7 @@ function buildParams( const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(context.systemPrompt && { systemInstruction: context.systemPrompt.toWellFormed() }), + ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), }; diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 492b0ef88..2277dff90 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -17,6 +17,7 @@ import type { ThinkingContent, ToolCall, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import type { GoogleThinkingLevel } from "./google-gemini-cli"; @@ -313,6 +314,7 @@ function buildParams( context: Context, options: GoogleOptions = {}, ): GenerateContentParameters { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); const contents = convertMessages(model, context); const generationConfig: GoogleSamplingConfig = {}; @@ -340,7 +342,7 @@ function buildParams( const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), - ...(context.systemPrompt && { systemInstruction: context.systemPrompt.toWellFormed() }), + ...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }), ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }), }; diff --git a/packages/ai/src/providers/ollama.ts b/packages/ai/src/providers/ollama.ts index 0b6885f1c..fb9f72092 100644 --- a/packages/ai/src/providers/ollama.ts +++ b/packages/ai/src/providers/ollama.ts @@ -14,6 +14,7 @@ import type { ToolResultMessage, UserMessage, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; import { parseStreamingJson } from "../utils/json-parse"; @@ -186,10 +187,14 @@ function convertMessage(message: Message): OllamaMessage { function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaMessage[] { const messages: Message[] = []; - if (context.systemPrompt) { + // Emit one developer message per ordered system prompt. The wire role is mapped to "system" + // by `convertMessage`, but keeping the prompts separate preserves prefix-cache stability: + // if only the trailing prompt changes between calls, the leading system messages keep + // their identical token prefix so KV-cache reuse covers them. + for (const systemPrompt of normalizeSystemPrompts(context.systemPrompt)) { messages.push({ role: "developer", - content: context.systemPrompt, + content: systemPrompt, timestamp: Date.now(), }); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index d1846da9e..f1d639289 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -36,6 +36,7 @@ import { getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, normalizeResponsesToolCallId, + normalizeSystemPrompts, } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector"; @@ -51,6 +52,7 @@ import { transformRequestBody, } from "./openai-codex/request-transformer"; import { parseCodexError } from "./openai-codex/response-handler"; +import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses"; import { encodeResponsesToolCallId, encodeTextSignatureV1, @@ -476,6 +478,7 @@ async function buildCodexRequestContext( const accountId = getAccountId(apiKey); const baseUrl = model.baseUrl || CODEX_BASE_URL; const url = resolveCodexResponsesUrl(baseUrl); + const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId); const transformedBody = await buildTransformedCodexRequestBody(model, context, options); options?.onPayload?.(transformedBody); @@ -490,8 +493,8 @@ async function buildCodexRequestContext( }; const providerSessionState = getCodexProviderSessionState(options?.providerSessionState); - const sessionKey = getCodexWebSocketSessionKey(options?.sessionId, model, accountId, baseUrl); - const publicSessionKey = getCodexPublicSessionKey(options?.sessionId, model, baseUrl); + const sessionKey = getCodexWebSocketSessionKey(promptCacheKey, model, accountId, baseUrl); + const publicSessionKey = getCodexPublicSessionKey(promptCacheKey, model, baseUrl); if (sessionKey && publicSessionKey) { providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey); } @@ -520,7 +523,7 @@ async function buildTransformedCodexRequestBody( model: model.id, input: [...convertMessages(model, context)], stream: true, - prompt_cache_key: options?.sessionId, + prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId), }; if (options?.maxTokens) { @@ -567,8 +570,11 @@ async function buildTransformedCodexRequestBody( } } - params.instructions = context.systemPrompt; - + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + if (systemPrompts.length > 0) { + params.instructions = systemPrompts[0]; + } + const developerMessages = systemPrompts.slice(1); const codexOptions: CodexRequestOptions = { reasoningEffort: options?.reasoning, reasoningSummary: options?.reasoningSummary ?? "auto", @@ -576,7 +582,7 @@ async function buildTransformedCodexRequestBody( include: options?.include, }; - return transformRequestBody(params, model, codexOptions); + return transformRequestBody(params, model, codexOptions, { developerMessages }); } async function openInitialCodexEventStream( @@ -628,7 +634,7 @@ async function openInitialCodexEventStream( async function openCodexWebSocketTransport( requestContext: CodexRequestContext, requestSetup: CodexRequestSetup, - options: OpenAICodexResponsesOptions | undefined, + _options: OpenAICodexResponsesOptions | undefined, websocketState: CodexWebSocketSessionState, retry: number, ): Promise<{ @@ -641,7 +647,7 @@ async function openCodexWebSocketTransport( requestContext.requestHeaders, requestContext.accountId, requestContext.apiKey, - options?.sessionId, + requestContext.transformedBody.prompt_cache_key, "websocket", websocketState, ); @@ -670,7 +676,7 @@ async function openCodexWebSocketTransport( async function openCodexSseTransport( requestContext: CodexRequestContext, requestSetup: CodexRequestSetup, - options: OpenAICodexResponsesOptions | undefined, + _options: OpenAICodexResponsesOptions | undefined, state: CodexWebSocketSessionState | undefined, body = requestContext.transformedBody, ): Promise<{ @@ -684,7 +690,7 @@ async function openCodexSseTransport( requestContext.requestHeaders, requestContext.accountId, requestContext.apiKey, - options?.sessionId, + body.prompt_cache_key, body, state, requestSetup.requestSignal, @@ -1559,9 +1565,10 @@ export async function prewarmOpenAICodexResponses( const accountId = getAccountId(apiKey); const baseUrl = model.baseUrl || CODEX_BASE_URL; const url = resolveCodexResponsesUrl(baseUrl); + const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId); const providerSessionState = getCodexProviderSessionState(options?.providerSessionState); - const sessionKey = getCodexWebSocketSessionKey(options?.sessionId, model, accountId, baseUrl); - const publicSessionKey = getCodexPublicSessionKey(options?.sessionId, model, baseUrl); + const sessionKey = getCodexWebSocketSessionKey(promptCacheKey, model, accountId, baseUrl); + const publicSessionKey = getCodexPublicSessionKey(promptCacheKey, model, baseUrl); if (publicSessionKey && sessionKey) { providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey); } @@ -1574,7 +1581,7 @@ export async function prewarmOpenAICodexResponses( { ...(model.headers ?? {}), ...(options?.headers ?? {}) }, accountId, apiKey, - options?.sessionId, + promptCacheKey, "websocket", state, ); @@ -1595,8 +1602,9 @@ function getCodexWebSocketSessionKey( accountId: string, baseUrl: string, ): string | undefined { - if (!sessionId || sessionId.length === 0) return undefined; - return `${accountId}:${baseUrl}:${model.id}:${sessionId}`; + const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(sessionId); + if (!promptCacheKey) return undefined; + return `${accountId}:${baseUrl}:${model.id}:${promptCacheKey}`; } function getCodexPublicSessionKey( @@ -1604,8 +1612,9 @@ function getCodexPublicSessionKey( model: Model<"openai-codex-responses">, baseUrl: string, ): string | undefined { - if (!sessionId || sessionId.length === 0) return undefined; - return `${baseUrl}:${model.id}:${sessionId}`; + const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(sessionId); + if (!promptCacheKey) return undefined; + return `${baseUrl}:${model.id}:${promptCacheKey}`; } function getCodexWebSocketSessionState( diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index 9afd4a39e..991688e00 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -77,7 +77,7 @@ export async function transformRequestBody( body: RequestBody, model: Model, options: CodexRequestOptions = {}, - prompt?: { instructions: string; developerMessages: string[] }, + prompt?: { developerMessages: string[] }, ): Promise { body.store = false; body.stream = true; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 151e1b350..8b9136193 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -33,6 +33,7 @@ import { type ToolChoice, type ToolResultMessage, } from "../types"; +import { normalizeSystemPrompts } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { toFireworksWireModelId } from "../utils/fireworks-model-id"; @@ -1178,10 +1179,13 @@ export function convertMessages( return generateFallbackToolCallId(seed); }; - if (context.systemPrompt) { + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + if (systemPrompts.length > 0) { const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; const role = useDeveloperRole ? "developer" : "system"; - params.push({ role: role, content: context.systemPrompt.toWellFormed() }); + for (const systemPrompt of systemPrompts) { + params.push({ role, content: systemPrompt }); + } } let lastRole: string | null = null; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index b83ed1ad4..4ef0d8777 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -25,6 +25,7 @@ import { createOpenAIResponsesHistoryPayload, getOpenAIResponsesHistoryItems, getOpenAIResponsesHistoryPayload, + normalizeSystemPrompts, resolveCacheRetention, sanitizeOpenAIResponsesHistoryItemsForReplay, } from "../utils"; @@ -73,6 +74,13 @@ function getPromptCacheRetention(baseUrl: string, cacheRetention: CacheRetention return undefined; } +export function normalizeOpenAIResponsesPromptCacheKey(sessionId: string | undefined): string | undefined { + if (!sessionId || sessionId.length === 0) return undefined; + const wellFormed = sessionId.toWellFormed(); + if (wellFormed.length <= 64) return wellFormed; + return `pc_${Bun.hash(wellFormed).toString(36)}`; +} + // OpenAI Responses-specific options export interface OpenAIResponsesOptions extends StreamOptions { reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -331,7 +339,9 @@ function createClient( function getOpenAIResponsesCacheSessionId( options: Pick | undefined, ): string | undefined { - return resolveCacheRetention(options?.cacheRetention) === "none" ? undefined : options?.sessionId; + return resolveCacheRetention(options?.cacheRetention) === "none" + ? undefined + : normalizeOpenAIResponsesPromptCacheKey(options?.sessionId); } function buildParams( @@ -352,12 +362,11 @@ function buildParams( ); const messages: ResponseInput = [...conversationMessages]; - if (context.systemPrompt) { - const role = model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model) ? "developer" : "system"; - messages.unshift({ - role, - content: context.systemPrompt.toWellFormed(), - }); + const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + if (systemPrompts.length > 0) { + const role: "developer" | "system" = + model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model) ? "developer" : "system"; + messages.unshift(...systemPrompts.map(systemPrompt => ({ role, content: systemPrompt }))); } const cacheRetention = resolveCacheRetention(options?.cacheRetention); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index da1e8c3ed..a09190d76 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -502,7 +502,7 @@ export interface Tool { } export interface Context { - systemPrompt?: string; + systemPrompt?: string[]; messages: Message[]; tools?: Tool[]; } diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 2266fc96b..0f2e6405d 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -5,6 +5,9 @@ import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } f type OpenAIResponsesReplayItem = ResponseInput[number]; export { isRecord } from "@oh-my-pi/pi-utils"; +export function normalizeSystemPrompts(systemPrompt: readonly string[] | undefined): string[] { + return systemPrompt?.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.length > 0) ?? []; +} export function toNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index f6c3f6f2e..9399c689c 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -125,7 +125,7 @@ describe("Anthropic request fingerprint alignment", () => { }); it("injects billing header and Claude Agent SDK identity block", () => { - const blocks = buildAnthropicSystemBlocks("Stay concise.", { + const blocks = buildAnthropicSystemBlocks(["Stay concise."], { includeClaudeCodeInstruction: true, extraInstructions: ["Use citations when possible"], }); @@ -147,18 +147,18 @@ describe("Anthropic request fingerprint alignment", () => { }); }); - it("applies cache_control to system blocks when cacheControl option is set", () => { - const blocks = buildAnthropicSystemBlocks("Stay concise.", { + it("attaches cache_control only to the last emitted system block when cacheControl is set", () => { + const blocks = buildAnthropicSystemBlocks(["Stay concise."], { includeClaudeCodeInstruction: true, extraInstructions: ["Use citations when possible"], cacheControl: { type: "ephemeral" }, }); expect(blocks).toBeDefined(); + // Earlier blocks must NOT carry cache_control; a single trailing breakpoint covers them all. expect(blocks?.[2]).toEqual({ type: "text", text: "Use citations when possible", - cache_control: { type: "ephemeral" }, }); expect(blocks?.[3]).toEqual({ type: "text", @@ -167,6 +167,22 @@ describe("Anthropic request fingerprint alignment", () => { }); }); + it("places the automatic Anthropic cache breakpoint on the last ordered system prompt", async () => { + const payload = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["stable system", "stable durable context"], + messages: [{ role: "user", content: "variable context", timestamp: Date.now() }], + }, + { isOAuth: false }, + )) as { system?: Array<{ type: string; text?: string; cache_control?: unknown }> }; + + expect(payload.system).toEqual([ + { type: "text", text: "stable system" }, + { type: "text", text: "stable durable context", cache_control: { type: "ephemeral" } }, + ]); + }); + it("uses Bearer auth for non-Anthropic API bases with api-key credentials", () => { const headers = buildAnthropicHeaders({ apiKey: "sk-ant-api-test", @@ -217,7 +233,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, id: "claude-3-5-haiku", name: "Claude 3.5 Haiku" }, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, )) as { system?: Array<{ type: string; text?: string }> }; @@ -241,7 +257,7 @@ describe("Anthropic request fingerprint alignment", () => { it("injects generated metadata.user_id for OAuth requests when missing", async () => { const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], })) as { metadata?: { user_id?: string } }; const userId = payload.metadata?.user_id; @@ -253,7 +269,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { isOAuth: false }, @@ -266,7 +282,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { metadata: { user_id: userId } }, @@ -279,7 +295,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { metadata: { user_id: "invalid-user-id" } }, @@ -328,7 +344,7 @@ describe("Anthropic request fingerprint alignment", () => { ]; const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], tools, })) as { @@ -390,7 +406,7 @@ describe("Anthropic request fingerprint alignment", () => { ]; const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], tools, })) as { @@ -429,7 +445,7 @@ describe("Anthropic request fingerprint alignment", () => { ]; const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], tools, })) as { @@ -470,7 +486,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], tools, }, @@ -531,7 +547,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], tools, }, @@ -787,7 +803,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { temperature: 0.2 }, @@ -801,7 +817,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( ANTHROPIC_MODEL, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { thinkingEnabled: false }, @@ -814,7 +830,7 @@ describe("Anthropic request fingerprint alignment", () => { const payload = (await captureAnthropicPayload( { ...ANTHROPIC_MODEL, id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { @@ -848,7 +864,7 @@ describe("Anthropic request fingerprint alignment", () => { }, }, { - systemPrompt: "Stay concise.", + systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], }, { diff --git a/packages/ai/test/azure-openai-responses-stream.test.ts b/packages/ai/test/azure-openai-responses-stream.test.ts index 3e500966e..0e799131e 100644 --- a/packages/ai/test/azure-openai-responses-stream.test.ts +++ b/packages/ai/test/azure-openai-responses-stream.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; +import { type AzureOpenAIResponsesOptions, streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses"; import type { Context, Model } from "../src/types"; const originalFetch = global.fetch; @@ -58,14 +58,19 @@ function createAssistantMessage(text: string, textSignature?: string) { }; } -async function captureAzurePayload(context: Context): Promise<{ input?: unknown[] }> { - const { promise, resolve } = Promise.withResolvers<{ input?: unknown[] }>(); - streamAzureOpenAIResponses(azureModel, context, { +async function captureAzurePayload( + context: Context, + model: Model<"azure-openai-responses"> = azureModel, + options: Partial = {}, +): Promise> { + const { promise, resolve } = Promise.withResolvers>(); + streamAzureOpenAIResponses(model, context, { apiKey: "test-key", - azureBaseUrl: azureModel.baseUrl, + azureBaseUrl: model.baseUrl, azureApiVersion: "v1", + ...options, signal: createAbortedSignal(), - onPayload: payload => resolve(payload as { input?: unknown[] }), + onPayload: payload => resolve(payload as Record), }); return promise; } @@ -76,6 +81,58 @@ afterEach(() => { }); describe("azure openai responses streaming", () => { + it("serializes each system prompt as an Azure Responses system input item for non-reasoning models", async () => { + const payload = await captureAzurePayload({ + systemPrompt: ["First instruction", "", "Second instruction"], + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }); + + expect(payload.input).toEqual([ + { role: "system", content: "First instruction" }, + { role: "system", content: "Second instruction" }, + { role: "user", content: [{ type: "input_text", text: "Say hello" }] }, + ]); + }); + + it("uses developer role for Azure Responses reasoning model system prompts", async () => { + const reasoningModel: Model<"azure-openai-responses"> = { + ...azureModel, + reasoning: true, + }; + const payload = await captureAzurePayload( + { + systemPrompt: ["Reasoning instruction", "Second instruction"], + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }, + reasoningModel, + ); + + expect(payload.input).toEqual([ + { role: "developer", content: "Reasoning instruction" }, + { role: "developer", content: "Second instruction" }, + { role: "user", content: [{ type: "input_text", text: "Say hello" }] }, + { + role: "developer", + content: [{ type: "input_text", text: "# Juice: 0 !important" }], + }, + ]); + }); + + it("keeps Azure Responses prompt_cache_key separate from Anthropic cache controls", async () => { + const payload = await captureAzurePayload( + { + systemPrompt: ["Cache-stable instruction"], + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }, + azureModel, + { sessionId: "azure-session" }, + ); + + expect(payload.prompt_cache_key).toBe("azure-session"); + expect(payload.prompt_cache_retention).toBeUndefined(); + expect(payload.cache_control).toBeUndefined(); + }); + it("surfaces nested response.failed provider errors", async () => { global.fetch = vi.fn(async () => createSseResponse([ diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 670250b85..a5a962869 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -57,7 +57,7 @@ async function testContextOverflow(model: Model, apiKey: string): Promise { it("invokes handler with correct this when passed as bound method", async () => { @@ -50,3 +50,18 @@ describe("Cursor resolveExecHandler execHandlers binding", () => { expect(execResult).toEqual({ tag: "error", message: expect.any(String) }); }); }); + +describe("Cursor system prompt encoding", () => { + it("emits one Cursor system blob per ordered prompt", () => { + const jsons = buildCursorSystemPromptJsons(["Primary instructions.", "Developer constraints."]); + expect(jsons).toHaveLength(2); + expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "Primary instructions." }); + expect(JSON.parse(jsons[1])).toEqual({ role: "system", content: "Developer constraints." }); + }); + + it("falls back to a single default system message when all entries are empty", () => { + const jsons = buildCursorSystemPromptJsons(["", ""]); + expect(jsons).toHaveLength(1); + expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "You are a helpful assistant." }); + }); +}); diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index b1b9f8bc5..5311f9c94 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -129,6 +129,25 @@ describe("Google Gemini CLI alignment", () => { expect(payload.userAgent).toBeUndefined(); expect(payload.requestId).toBeUndefined(); }); + it("keeps every system prompt block in systemInstruction instead of conversation contents", () => { + const model = createModel("google-gemini-cli"); + const context: Context = { + systemPrompt: ["primary instruction", "", "supplemental \uD800instruction"], + messages: [{ role: "user", content: "implement token refresh", timestamp: Date.now() }], + }; + const payload = buildRequest(model, context, "proj-123", {}, false) as { + request: { + contents: Array<{ role?: string; parts?: Array<{ text?: string }> }>; + systemInstruction?: { role?: string; parts: Array<{ text: string }> }; + }; + }; + + expect(payload.request.systemInstruction).toEqual({ + parts: [{ text: "primary instruction" }, { text: "supplemental �instruction" }], + }); + expect(payload.request.systemInstruction?.role).toBeUndefined(); + expect(payload.request.contents).toEqual([{ role: "user", parts: [{ text: "implement token refresh" }] }]); + }); it("keeps antigravity metadata in antigravity request payloads", () => { const model = createModel("google-antigravity"); diff --git a/packages/ai/test/google-system-prompt.test.ts b/packages/ai/test/google-system-prompt.test.ts new file mode 100644 index 000000000..42b8a29e9 --- /dev/null +++ b/packages/ai/test/google-system-prompt.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { Models } from "@google/genai"; +import { streamGoogle } from "@oh-my-pi/pi-ai/providers/google"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; + +const model: Model<"google-generative-ai"> = { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 32_000, +}; + +async function captureGooglePayload( + context: Context, +): Promise<{ config: { systemInstruction?: unknown }; contents: unknown[] }> { + let captured: { config: { systemInstruction?: unknown }; contents: unknown[] } | undefined; + vi.spyOn(Models.prototype, "generateContentStream").mockImplementation(async function* () { + // No chunks needed; the test only validates the generated request payload. + } as never); + + await streamGoogle(model, context, { + apiKey: "test-key", + onPayload: payload => { + captured = payload as { config: { systemInstruction?: unknown }; contents: unknown[] }; + }, + }).result(); + + expect(captured).toBeDefined(); + return captured!; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Google provider system prompts", () => { + it("sends every system prompt block as systemInstruction text parts", async () => { + const payload = await captureGooglePayload({ + systemPrompt: ["primary instruction", "secondary instruction"], + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }); + + expect(payload.config.systemInstruction).toEqual({ + parts: [{ text: "primary instruction" }, { text: "secondary instruction" }], + }); + expect(payload.contents).toEqual([{ role: "user", parts: [{ text: "hello" }] }]); + }); + + it("does not inject extra user turns before signed model history", async () => { + const payload = await captureGooglePayload({ + systemPrompt: ["stable instruction", "cacheable instruction"], + messages: [ + { + role: "assistant", + api: "google-generative-ai", + provider: "google", + model: model.id, + content: [{ type: "thinking", thinking: "prior thought", thinkingSignature: "QUJDRA==" }], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }, + ], + }); + + expect(payload.contents[0]).toEqual({ + role: "model", + parts: [{ thought: true, text: "prior thought", thoughtSignature: "QUJDRA==" }], + }); + expect(payload.contents).toHaveLength(1); + }); +}); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 2a5049008..2e01860cd 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -45,7 +45,7 @@ async function handleToolWithImageResult(model: Model, o }; const context: Context = { - systemPrompt: "You are a helpful assistant that uses tools when asked.", + systemPrompt: ["You are a helpful assistant that uses tools when asked."], messages: [ { role: "user", @@ -133,7 +133,7 @@ async function handleToolWithTextAndImageResult(model: Model { function makeContext(): Context { return { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; } diff --git a/packages/ai/test/ollama-cloud-provider.test.ts b/packages/ai/test/ollama-cloud-provider.test.ts index 015853a3b..922fc9349 100644 --- a/packages/ai/test/ollama-cloud-provider.test.ts +++ b/packages/ai/test/ollama-cloud-provider.test.ts @@ -429,6 +429,36 @@ describe("ollama-cloud provider support", () => { }); }); + test("emits one Ollama system message per ordered system prompt entry", async () => { + let requestBody: Record | undefined; + global.fetch = vi.fn(async (_input, init) => { + requestBody = JSON.parse(String(init?.body ?? "{}")) as Record; + return createNdjsonResponse([ + { + model: "gpt-oss:120b", + message: { role: "assistant", content: "done" }, + done: false, + }, + { model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 3, eval_count: 1 }, + ]); + }) as unknown as typeof fetch; + + await stream( + cloudModel, + { + systemPrompt: ["Stable instruction.", "Extra policy."], + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }, + { apiKey: "cloud-test-key" }, + ).result(); + + const messages = requestBody?.messages as Array> | undefined; + expect(messages).toHaveLength(3); + expect(messages?.[0]).toEqual({ role: "system", content: "Stable instruction." }); + expect(messages?.[1]).toEqual({ role: "system", content: "Extra policy." }); + expect(messages?.map(message => message.role)).toEqual(["system", "system", "user"]); + }); + describe("mapToolChoice", () => { test("omits tool_choice when undefined or auto", async () => { let requestBody: Record | undefined; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 48c9e08b9..20380387a 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -64,7 +64,7 @@ function createCodexTestModel(baseUrl?: string): Model<"openai-codex-responses"> function createCodexTestContext(): Context { return { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; } @@ -417,7 +417,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -480,7 +480,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -550,7 +550,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -594,7 +594,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -650,7 +650,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -714,7 +714,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -817,7 +817,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -910,7 +910,7 @@ describe("openai-codex streaming", () => { }); const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -1002,7 +1002,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -1068,7 +1068,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1137,7 +1137,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1220,7 +1220,7 @@ describe("openai-codex streaming", () => { preferWebsockets: false, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1301,7 +1301,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -1366,7 +1366,7 @@ describe("openai-codex streaming", () => { }; const providerSessionState = new Map(); const firstContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant.", "Use concise answers."], messages: [{ role: "user", content: "First question", timestamp: Date.now() }], }; const firstResponse = await streamOpenAICodexResponses(model, firstContext, { @@ -1375,7 +1375,7 @@ describe("openai-codex streaming", () => { providerSessionState, }).result(); const secondContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant.", "Use concise answers."], messages: [ ...firstContext.messages, firstResponse, @@ -1392,9 +1392,18 @@ describe("openai-codex streaming", () => { expect(sentRequests).toHaveLength(2); expect(sentRequests[0]?.previous_response_id).toBeUndefined(); expect(sentRequests[0]?.prompt_cache_key).toBe("ws-delta-session"); + expect(sentRequests[0]?.instructions).toBe("You are a helpful assistant."); + const initialInput = sentRequests[0]?.input; + expect(Array.isArray(initialInput)).toBe(true); + const initialItems = initialInput as Array<{ role?: string; content?: unknown }>; + expect(initialItems).toHaveLength(2); + expect(initialItems[0]?.role).toBe("developer"); + expect(JSON.stringify(initialItems[0]?.content)).toContain("Use concise answers."); + expect(initialItems[1]?.role).toBe("user"); expect(sentRequests[1]?.type).toBe("response.create"); expect(sentRequests[1]?.previous_response_id).toBe("resp_1"); expect(sentRequests[1]?.prompt_cache_key).toBe("ws-delta-session"); + expect(sentRequests[1]?.instructions).toBe("You are a helpful assistant."); const deltaInput = sentRequests[1]?.input; expect(Array.isArray(deltaInput)).toBe(true); const deltaItems = deltaInput as Array<{ role?: string }>; @@ -1478,7 +1487,7 @@ describe("openai-codex streaming", () => { const model = createCodexTestModel("https://chatgpt.com/backend-api"); const providerSessionState = new Map(); const firstContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "First question", timestamp: Date.now() }], }; const firstResponse = await streamOpenAICodexResponses(model, firstContext, { @@ -1487,7 +1496,7 @@ describe("openai-codex streaming", () => { providerSessionState, }).result(); const secondContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ ...firstContext.messages, firstResponse, @@ -1558,7 +1567,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; @@ -1615,7 +1624,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1679,7 +1688,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1764,7 +1773,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1838,7 +1847,7 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -1936,18 +1945,18 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const firstContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const secondContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", content: "Say hello", timestamp: Date.now() }, { role: "user", content: "Keep going", timestamp: Date.now() + 1 }, ], }; const thirdContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", content: "Say hello", timestamp: Date.now() }, { role: "user", content: "Keep going", timestamp: Date.now() + 1 }, @@ -2053,18 +2062,18 @@ describe("openai-codex streaming", () => { maxTokens: 128000, }; const firstContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const secondContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", content: "Say hello", timestamp: Date.now() }, { role: "user", content: "Keep going", timestamp: Date.now() + 1 }, ], }; const thirdContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", content: "Say hello", timestamp: Date.now() }, { role: "user", content: "Keep going", timestamp: Date.now() + 1 }, @@ -2151,7 +2160,7 @@ describe("openai-codex streaming", () => { const result = await streamOpenAICodexResponses( model, { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }, { @@ -2231,7 +2240,7 @@ describe("openai-codex streaming", () => { const result = await streamOpenAICodexResponses( model, { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }, { @@ -2319,11 +2328,11 @@ describe("openai-codex streaming", () => { preferWebsockets: false, }; const firstContext: Context = { - systemPrompt: "Prompt A", + systemPrompt: ["Prompt A"], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const secondContext: Context = { - systemPrompt: "Prompt B", + systemPrompt: ["Prompt B"], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; const providerSessionState = new Map(); @@ -2406,11 +2415,11 @@ describe("openai-codex streaming", () => { await prewarmOpenAICodexResponses(model, { apiKey: token, sessionId: "ws-reuse-session", providerSessionState }); const firstContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "First", timestamp: Date.now() }], }; const secondContext: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", content: "First", timestamp: Date.now() }, { role: "user", content: "Second", timestamp: Date.now() }, @@ -2486,7 +2495,7 @@ describe("openai-codex streaming", () => { }; const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 7d2da2e0a..4e2535cc7 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -120,6 +120,66 @@ describe("openai-completions compatibility", () => { expect(assistant.content).toBe("hello world"); }); + it("preserves multiple system prompts as leading system messages for chat completions", () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + }; + + const messages = convertMessages( + model, + { + systemPrompt: ["stable instructions", "cacheable policy"], + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }, + detectCompat(model), + ); + + expect(messages.slice(0, 3)).toEqual([ + { role: "system", content: "stable instructions" }, + { role: "system", content: "cacheable policy" }, + { role: "user", content: "hello" }, + ]); + }); + + it("uses developer messages for reasoning chat models only when the target supports them", () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + reasoning: true, + }; + + const supportedMessages = convertMessages( + model, + { + systemPrompt: ["stable instructions", "cacheable policy"], + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }, + detectCompat(model), + ); + + expect(supportedMessages.slice(0, 3)).toEqual([ + { role: "developer", content: "stable instructions" }, + { role: "developer", content: "cacheable policy" }, + { role: "user", content: "hello" }, + ]); + + const unsupportedMessages = convertMessages( + model, + { + systemPrompt: ["stable instructions", "cacheable policy"], + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }, + { ...detectCompat(model), supportsDeveloperRole: false }, + ); + + expect(unsupportedMessages.slice(0, 3)).toEqual([ + { role: "system", content: "stable instructions" }, + { role: "system", content: "cacheable policy" }, + { role: "user", content: "hello" }, + ]); + }); + it("reads usage from choice usage fallback", async () => { const model: Model<"openai-completions"> = { ...getBundledModel("openai", "gpt-4o-mini"), diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index f7bccf6f9..cc0d6e994 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { getBundledModel } from "../src/models"; -import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses"; -import type { Model } from "../src/types"; +import { + normalizeOpenAIResponsesPromptCacheKey, + type OpenAIResponsesOptions, + streamOpenAIResponses, +} from "../src/providers/openai-responses"; +import type { Context, Model } from "../src/types"; const originalFetch = global.fetch; const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; @@ -20,11 +24,16 @@ function getHeader(headers: RequestInit["headers"], name: string): string | null async function captureOpenAIResponseHeaders( options: OpenAIResponsesOptions, -): Promise<{ sessionId: string | null; clientRequestId: string | null }> { - const captured = { sessionId: null as string | null, clientRequestId: null as string | null }; +): Promise<{ sessionId: string | null; clientRequestId: string | null; body: Record | null }> { + const captured = { + sessionId: null as string | null, + clientRequestId: null as string | null, + body: null as Record | null, + }; const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { captured.sessionId = getHeader(init?.headers, "session_id"); captured.clientRequestId = getHeader(init?.headers, "x-client-request-id"); + captured.body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record) : null; return createSseResponse([ { type: "response.output_item.added", @@ -58,14 +67,11 @@ async function captureOpenAIResponseHeaders( }); global.fetch = Object.assign(fetchMock, { preconnect: originalFetch.preconnect }) as typeof fetch; - const stream = streamOpenAIResponses( - model, - { - systemPrompt: "sys", - messages: [{ role: "user", content: "hi", timestamp: Date.now() }], - }, - { apiKey: "test-key", ...options }, - ); + const context: Context = { + systemPrompt: ["stable system", "stable durable context"], + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }; + const stream = streamOpenAIResponses(model, context, { apiKey: "test-key", ...options }); for await (const event of stream) { if (event.type === "done" || event.type === "error") break; @@ -83,7 +89,9 @@ describe("openai-responses cache affinity", () => { it("sets session routing headers for official OpenAI Responses requests with a sessionId", async () => { const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); - expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); + expect(captured.sessionId).toBe("session-123"); + expect(captured.clientRequestId).toBe("session-123"); + expect(captured.body?.prompt_cache_key).toBe("session-123"); }); it("lets explicit headers override the default OpenAI session routing headers", async () => { @@ -95,12 +103,35 @@ describe("openai-responses cache affinity", () => { }, }); - expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" }); + expect(captured.sessionId).toBe("override-session"); + expect(captured.clientRequestId).toBe("override-request"); + expect(captured.body?.prompt_cache_key).toBe("session-123"); }); it("omits OpenAI session routing headers when cache retention is disabled", async () => { const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" }); - expect(captured).toEqual({ sessionId: null, clientRequestId: null }); + expect(captured.sessionId).toBeNull(); + expect(captured.clientRequestId).toBeNull(); + expect(captured.body?.prompt_cache_key).toBeUndefined(); + }); + + it("normalizes long prompt cache keys while preserving ordered system prompts", async () => { + const longSessionId = "session-".repeat(20); + const expectedCacheKey = normalizeOpenAIResponsesPromptCacheKey(longSessionId); + if (!expectedCacheKey) throw new Error("Expected normalized prompt cache key"); + const captured = await captureOpenAIResponseHeaders({ sessionId: longSessionId }); + + expect(captured.sessionId).toBe(expectedCacheKey); + expect(captured.clientRequestId).toBe(expectedCacheKey); + expect(captured.body?.prompt_cache_key).toBe(expectedCacheKey); + expect(expectedCacheKey?.length).toBeLessThanOrEqual(64); + + const input = captured.body?.input; + expect(Array.isArray(input)).toBe(true); + expect((input as Array<{ role?: string; content?: string }>).slice(0, 2)).toEqual([ + { role: "developer", content: "stable system" }, + { role: "developer", content: "stable durable context" }, + ]); }); }); diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 39cf6d83e..5eb0ae1a9 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; -import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "../src/utils"; @@ -165,12 +165,14 @@ function captureResponsesPayload( model: Model<"openai-responses">, context: Context, providerSessionState?: Map, + options?: Omit, ): Promise { const { promise, resolve } = Promise.withResolvers(); streamOpenAIResponses(model, context, { apiKey: "test-key", signal: createAbortedSignal(), providerSessionState, + ...options, onPayload: payload => resolve(payload), }); return promise; @@ -285,6 +287,58 @@ function containsUserInputText(input: unknown[] | undefined, text: string): bool } describe("OpenAI responses history payload", () => { + it("prepends multiple OpenAI developer instructions in order without changing prompt cache key routing", async () => { + const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; + const payload = (await captureResponsesPayload( + model, + { + systemPrompt: ["stable instructions", "second instructions"], + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + undefined, + { sessionId: "session-abc" }, + )) as { input?: unknown[]; prompt_cache_key?: unknown }; + + expect(payload.input).toEqual([ + { role: "developer", content: "stable instructions" }, + { role: "developer", content: "second instructions" }, + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + expect(payload.prompt_cache_key).toBe("session-abc"); + }); + + it("falls back to system instructions for OpenAI-compatible endpoints without developer-role support", async () => { + const model = { + ...(getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">), + baseUrl: "https://proxy.example.com/v1", + }; + const payload = (await captureResponsesPayload(model, { + systemPrompt: ["stable instructions", "second instructions"], + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + })) as { input?: unknown[] }; + + expect(payload.input).toEqual([ + { role: "system", content: "stable instructions" }, + { role: "system", content: "second instructions" }, + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + it("keeps system instruction order ahead of replayed native history", async () => { + const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; + const payload = (await captureResponsesPayload(model, { + ...assistantSnapshotContext, + systemPrompt: ["stable instructions", "second instructions"], + })) as { input?: unknown[] }; + + expect(payload.input).toEqual([ + { role: "developer", content: "stable instructions" }, + { role: "developer", content: "second instructions" }, + ...snapshotHistoryItems, + { role: "user", content: [{ type: "input_text", text: "follow-up user" }] }, + ]); + }); + it("inlines preserved replacement history for openai-responses", async () => { const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; const payload = (await captureResponsesPayload(model, preservedHistoryContext)) as { input?: unknown[] }; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 324de9db2..44b8290fa 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -52,7 +52,7 @@ const calculatorTool: Tool = { async function basicTextGeneration(model: Model, options?: OptionsForApi) { const context: Context = { - systemPrompt: "You are a helpful assistant. Be concise.", + systemPrompt: ["You are a helpful assistant. Be concise."], messages: [{ role: "user", content: "Reply with exactly: 'Hello test successful'", timestamp: Date.now() }], }; const response = await complete(model, context, options); @@ -81,7 +81,7 @@ async function basicTextGeneration(model: Model, options async function handleToolCall(model: Model, options?: OptionsForApi) { const context: Context = { - systemPrompt: "You are a helpful assistant that uses tools when asked.", + systemPrompt: ["You are a helpful assistant that uses tools when asked."], messages: [ { role: "user", @@ -272,7 +272,7 @@ async function handleImage(model: Model, options?: Optio async function multiTurn(model: Model, options?: OptionsForApi) { const context: Context = { - systemPrompt: "You are a helpful assistant that can use tools to answer questions.", + systemPrompt: ["You are a helpful assistant that can use tools to answer questions."], messages: [ { role: "user", @@ -477,6 +477,43 @@ describe("Generate E2E Tests", () => { } }); + it("keeps every system prompt array entry in Vertex systemInstruction", async () => { + const llm = getBundledModel("google-vertex", "gemini-3-flash-preview"); + const controller = new AbortController(); + const { promise, resolve } = Promise.withResolvers<{ + config: { systemInstruction?: unknown }; + contents: unknown[]; + }>(); + const events = stream( + llm, + { + systemPrompt: ["Primary instruction.", "Secondary instruction."], + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }, + { + apiKey: "vertex-test-key", + signal: controller.signal, + onPayload: payload => { + resolve(payload as { config: { systemInstruction?: unknown }; contents: unknown[] }); + controller.abort(); + }, + }, + ); + + const drain = (async () => { + for await (const _event of events) { + } + })(); + + const payload = await promise; + await drain; + + expect(payload.config.systemInstruction).toEqual({ + parts: [{ text: "Primary instruction." }, { text: "Secondary instruction." }], + }); + expect(payload.contents).toEqual([{ role: "user", parts: [{ text: "Hello" }] }]); + }); + it("allows explicit Vertex API keys without requiring project or location", async () => { const originalApiKey = Bun.env.GOOGLE_CLOUD_API_KEY; const originalProject = Bun.env.GOOGLE_CLOUD_PROJECT; @@ -1471,7 +1508,7 @@ describe("Generate E2E Tests", () => { const response = await complete( llm, { - systemPrompt: "You are a helpful assistant that uses tools when asked.", + systemPrompt: ["You are a helpful assistant that uses tools when asked."], messages: [ { role: "user", diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 08a0478af..133ecbb42 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -32,7 +32,7 @@ async function testToolCallWithoutResult( ) { // Step 1: Create context with the calculate tool const context: Context = { - systemPrompt: "You are a helpful assistant. Use the calculate tool when asked to perform calculations.", + systemPrompt: ["You are a helpful assistant. Use the calculate tool when asked to perform calculations."], messages: [], tools: [calculateTool], }; diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 1677d47b0..200e4d0b9 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -47,7 +47,7 @@ async function testTotalTokensWithCache( ): Promise<{ first: Usage; second: Usage }> { // First request - no cache const context1: Context = { - systemPrompt: LONG_SYSTEM_PROMPT, + systemPrompt: [LONG_SYSTEM_PROMPT], messages: [ { role: "user", @@ -62,7 +62,7 @@ async function testTotalTokensWithCache( // Second request - should trigger cache read (same system prompt, add conversation) const context2: Context = { - systemPrompt: LONG_SYSTEM_PROMPT, + systemPrompt: [LONG_SYSTEM_PROMPT], messages: [ ...context1.messages, response1, // Include previous assistant response diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index b923400c7..110ea6f06 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -32,7 +32,7 @@ const [anthropicOAuthToken, githubCopilotToken, geminiCliToken, antigravityToken async function testEmojiInToolResults(llm: Model, options: OptionsForApi = {}) { // Simulate a tool that returns emoji const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", @@ -117,7 +117,7 @@ async function testEmojiInToolResults(llm: Model, option async function testRealWorldLinkedInData(llm: Model, options: OptionsForApi = {}) { const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", @@ -205,7 +205,7 @@ Unanswered Comments: 2 async function testUnpairedHighSurrogate(llm: Model, options: OptionsForApi = {}) { const context: Context = { - systemPrompt: "You are a helpful assistant.", + systemPrompt: ["You are a helpful assistant."], messages: [ { role: "user", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 284d81dfc..768aac34f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,14 +1,18 @@ # Changelog ## [Unreleased] - ### Breaking Changes -- +- Changed session system-prompt APIs to use ordered string block arrays by requiring `buildSystemPrompt`, `CreateAgentSessionOptions.systemPrompt`, `Session.rebuildSystemPrompt`, and extension `before_agent_start`/`getSystemPrompt` hooks to accept and return `systemPrompt: string[]` instead of a plain system-prompt string or separate `projectPrompt` field +- Changed `buildSystemPrompt` and session `rebuildSystemPrompt` APIs to return `{ systemPrompt, projectPrompt }`, requiring callers expecting a plain system prompt string to update to the new shape - Removed the top-level `sel` parameter from the `read` tool schema, requiring callers to migrate to `path`-embedded selectors (for example `path:50-100`, `path:raw`, or `https://...:L1-L40`) ### Added +- Added a separate `projectPrompt` artifact containing per-session project context (workstation, context files, AGENTS.md rules, workspace tree, and append prompt) so dynamic context is decoupled from the static system prompt +- Added `Project prompt` token accounting to context-usage breakdowns and charts +- Added `tools.elideFileMutationInputs` setting to optionally elide large `write`, `edit`, and `apply_patch` payloads in history after successful mutations +- Added hashline-style return data for elided `write` calls so tools can include the resulting file content without leaking full input text - Added `buildDirectoryTree` and `DirectoryTree` exports to generate configurable directory trees with options for depth, entry limits, hidden-file handling, and truncation caps - Added `buildWorkspaceTree` and `WorkspaceTree` exports so callers can precompute and pass a workspace context to prompt generation - Added `workspaceTree` support to `buildSystemPrompt` options to reuse a prebuilt directory snapshot @@ -17,6 +21,11 @@ ### Changed +- Updated session dump and HTML export output to serialize ordered system-prompt blocks (including project context) and removed the dedicated project-prompt dump section +- Renamed context-usage system-prompt accounting from a separate `projectPrompt` bucket to `systemContext` to match the new multi-block prompt structure +- Changed prompt delivery to inject non-empty `projectPrompt` as a leading `developer` message before conversation messages instead of merging it into the base system prompt +- Added `projectPrompt` to session dumps to expose the injected per-session project context separately +- Changed write success output and preview rendering to display hashline-formatted written content from captured file text when mutation inputs are elided - Changed `read` directory rendering to return a two-level recency-sorted directory tree (including nested folders) instead of a flat alphabetical entry list, while still applying configurable truncation - Changed generated system prompts to include a working-directory tree block after directory context, showing recent files/directories (depth ≤ 3) and truncation notices when entries are elided - Changed `read` summary rendering to merge opening- and closing-brace boundaries around elided sections into a single `..` line (including closers like `};` or `})`), reducing those segments to one concise anchored summary line diff --git a/packages/coding-agent/examples/hooks/handoff.ts b/packages/coding-agent/examples/hooks/handoff.ts index 96e404de4..d1b86db78 100644 --- a/packages/coding-agent/examples/hooks/handoff.ts +++ b/packages/coding-agent/examples/hooks/handoff.ts @@ -94,7 +94,7 @@ export default function (pi: HookAPI) { const response = await complete( ctx.model!, - { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { systemPrompt: [SYSTEM_PROMPT], messages: [userMessage] }, { apiKey, signal: loader.signal }, ); diff --git a/packages/coding-agent/examples/hooks/qna.ts b/packages/coding-agent/examples/hooks/qna.ts index 3dda3e1db..dbc9cf687 100644 --- a/packages/coding-agent/examples/hooks/qna.ts +++ b/packages/coding-agent/examples/hooks/qna.ts @@ -85,7 +85,7 @@ export default function (pi: HookAPI) { const response = await complete( ctx.model!, - { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { systemPrompt: [SYSTEM_PROMPT], messages: [userMessage] }, { apiKey, signal: loader.signal }, ); diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts index ceff8c0dd..420b48665 100644 --- a/packages/coding-agent/examples/sdk/03-custom-prompt.ts +++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts @@ -7,8 +7,10 @@ import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent"; // Option 1: Replace prompt entirely const { session: session1 } = await createAgentSession({ - systemPrompt: `You are a helpful assistant that speaks like a pirate. + systemPrompt: [ + `You are a helpful assistant that speaks like a pirate. Always end responses with "Arrr!"`, + ], sessionManager: SessionManager.inMemory(), }); @@ -24,11 +26,12 @@ console.log("\n"); // Option 2: Modify default prompt (receives default, returns modified) const { session: session2 } = await createAgentSession({ - systemPrompt: defaultPrompt => `${defaultPrompt} - -## Additional Instructions + systemPrompt: defaultPrompt => [ + ...defaultPrompt, + `## Additional Instructions - Always be concise - Use bullet points when listing things`, + ], sessionManager: SessionManager.inMemory(), }); diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index 66d45f0c4..faf2f208b 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -87,7 +87,7 @@ const { session } = await createAgentSession({ model, authStorage: customAuth, modelRegistry: customRegistry, - systemPrompt: "You are helpful.", + systemPrompt: ["You are helpful."], toolNames: ["read", "bash"], customTools: [{ tool: myTool }], hooks: [{ factory: myHook }], diff --git a/packages/coding-agent/src/autoresearch/index.ts b/packages/coding-agent/src/autoresearch/index.ts index 9491ffb78..4bbb07cb8 100644 --- a/packages/coding-agent/src/autoresearch/index.ts +++ b/packages/coding-agent/src/autoresearch/index.ts @@ -356,54 +356,58 @@ export const createAutoresearchExtension: ExtensionFactory = api => { ? null : "Heads up: you are not on a dedicated `autoresearch/*` branch. `log_experiment discard` will only revert run-modified files, not reset to baseline — so harness files written before `init_experiment` may not survive a discard. Clean the worktree and re-run `/autoresearch` if you want full revert safety."; return { - systemPrompt: prompt.render(setupPromptTemplate, { - base_system_prompt: event.systemPrompt, - has_goal: goal.trim().length > 0, - goal, - working_dir: ctx.cwd, - has_branch: Boolean(currentBranch), - branch: currentBranch ?? "", - has_baseline_warning: baselineWarning !== null, - baseline_warning: baselineWarning ?? "", - }), + systemPrompt: [ + prompt.render(setupPromptTemplate, { + base_system_prompt: event.systemPrompt.join("\n\n"), + has_goal: goal.trim().length > 0, + goal, + working_dir: ctx.cwd, + has_branch: Boolean(currentBranch), + branch: currentBranch ?? "", + has_baseline_warning: baselineWarning !== null, + baseline_warning: baselineWarning ?? "", + }), + ], }; } return { - systemPrompt: prompt.render(promptTemplate, { - base_system_prompt: event.systemPrompt, - has_goal: goal.trim().length > 0, - goal, - working_dir: ctx.cwd, - default_metric_name: state.metricName, - metric_name: state.metricName, - has_branch: Boolean(state.branch), - branch: state.branch, - has_baseline_commit: Boolean(state.baselineCommit), - baseline_commit: state.baselineCommit ? state.baselineCommit.slice(0, 12) : "", - has_notes: state.notes.trim().length > 0, - notes: state.notes, - current_segment: state.currentSegment + 1, - current_segment_run_count: currentSegmentResults.length, - has_baseline_metric: baselineMetric !== null, - baseline_metric_display: formatNum(baselineMetric, state.metricUnit), - baseline_run_number: baselineRunNumber, - has_best_result: bestResult !== null && bestMetric !== null, - best_metric_display: bestMetric !== null ? formatNum(bestMetric, state.metricUnit) : "-", - best_run_number: bestResult ? (bestResult.runNumber ?? state.results.indexOf(bestResult) + 1) : null, - has_recent_results: recentResults.length > 0, - recent_results: recentResults, - has_unjustified_runs: unjustifiedRuns.length > 0, - unjustified_runs: unjustifiedRuns, - has_pending_run: Boolean(pendingRun), - pending_run_number: pendingRun?.runNumber, - pending_run_command: pendingRun?.command, - pending_run_passed: pendingRun?.passed ?? false, - has_pending_run_metric: pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined, - pending_run_metric_display: - pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined - ? formatNum(pendingRun.parsedPrimary, state.metricUnit) - : null, - }), + systemPrompt: [ + prompt.render(promptTemplate, { + base_system_prompt: event.systemPrompt.join("\n\n"), + has_goal: goal.trim().length > 0, + goal, + working_dir: ctx.cwd, + default_metric_name: state.metricName, + metric_name: state.metricName, + has_branch: Boolean(state.branch), + branch: state.branch, + has_baseline_commit: Boolean(state.baselineCommit), + baseline_commit: state.baselineCommit ? state.baselineCommit.slice(0, 12) : "", + has_notes: state.notes.trim().length > 0, + notes: state.notes, + current_segment: state.currentSegment + 1, + current_segment_run_count: currentSegmentResults.length, + has_baseline_metric: baselineMetric !== null, + baseline_metric_display: formatNum(baselineMetric, state.metricUnit), + baseline_run_number: baselineRunNumber, + has_best_result: bestResult !== null && bestMetric !== null, + best_metric_display: bestMetric !== null ? formatNum(bestMetric, state.metricUnit) : "-", + best_run_number: bestResult ? (bestResult.runNumber ?? state.results.indexOf(bestResult) + 1) : null, + has_recent_results: recentResults.length > 0, + recent_results: recentResults, + has_unjustified_runs: unjustifiedRuns.length > 0, + unjustified_runs: unjustifiedRuns, + has_pending_run: Boolean(pendingRun), + pending_run_number: pendingRun?.runNumber, + pending_run_command: pendingRun?.command, + pending_run_passed: pendingRun?.passed ?? false, + has_pending_run_metric: pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined, + pending_run_metric_display: + pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined + ? formatNum(pendingRun.parsedPrimary, state.metricUnit) + : null, + }), + ], }; }); diff --git a/packages/coding-agent/src/commit/agentic/agent.ts b/packages/coding-agent/src/commit/agentic/agent.ts index 43c35a380..8ae14e79c 100644 --- a/packages/coding-agent/src/commit/agentic/agent.ts +++ b/packages/coding-agent/src/commit/agentic/agent.ts @@ -60,7 +60,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise = { async computeDiffPreview(args, ctx) { if (typeof args.input !== "string" || args.input.length === 0) return null; ctx.signal.throwIfAborted(); - const result = await computeHashlineDiff( - { input: args.input, path: args.path }, - ctx.cwd, - { autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates }, - ); + const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, { + autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates, + }); ctx.signal.throwIfAborted(); if ("error" in result && !args.path) return [{ path: "", error: result.error }]; return [toPerFilePreview(args.path ?? "", result)]; diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts index 0c6b8576b..71f367188 100644 --- a/packages/coding-agent/src/export/html/index.ts +++ b/packages/coding-agent/src/export/html/index.ts @@ -124,7 +124,7 @@ export async function exportSessionToHtml( header: sm.getHeader(), entries: sm.getEntries(), leafId: sm.getLeafId(), - systemPrompt: state?.systemPrompt, + systemPrompt: state?.systemPrompt.join("\n\n"), tools: state?.tools?.map(t => ({ name: t.name, description: t.description })), }; diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 304c14bac..5a231277a 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -55,7 +55,7 @@ import type { /** Combined result from all before_agent_start handlers */ interface BeforeAgentStartCombinedResult { messages?: NonNullable[]; - systemPrompt?: string; + systemPrompt?: string[]; } export type ExtensionErrorListener = (error: ExtensionError) => void; @@ -168,7 +168,7 @@ export class ExtensionRunner { #hasPendingMessagesFn: () => boolean = () => false; #getContextUsageFn: () => ContextUsage | undefined = () => undefined; #compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise = async () => {}; - #getSystemPromptFn: () => string = () => ""; + #getSystemPromptFn: () => string[] = () => []; #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); #branchHandler: BranchHandler = async () => ({ cancelled: false }); #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); @@ -795,7 +795,7 @@ export class ExtensionRunner { async emitBeforeAgentStart( prompt: string, images: ImageContent[] | undefined, - systemPrompt: string, + systemPrompt: string[], ): Promise { const ctx = this.createContext(); const messages: NonNullable[] = []; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index cb1a93c64..7dd91231f 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -240,7 +240,7 @@ export interface ExtensionContext { /** Gracefully shutdown and exit. */ shutdown(): void; /** Get the current effective system prompt. */ - getSystemPrompt(): string; + getSystemPrompt(): string[]; /** @deprecated Use hasPendingMessages() instead */ hasQueuedMessages(): boolean; } @@ -492,7 +492,7 @@ export interface BeforeAgentStartEvent { type: "before_agent_start"; prompt: string; images?: ImageContent[]; - systemPrompt: string; + systemPrompt: string[]; } /** Fired when an agent loop starts */ @@ -876,7 +876,7 @@ export interface ToolResultEventResult { export interface BeforeAgentStartEventResult { message?: Pick; /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */ - systemPrompt?: string; + systemPrompt?: string[]; } export interface SessionBeforeSwitchResult { @@ -1318,7 +1318,7 @@ export interface ExtensionContextActions { shutdown: () => void; getContextUsage: () => ContextUsage | undefined; compact: (instructionsOrOptions?: string | CompactOptions) => Promise; - getSystemPrompt: () => string; + getSystemPrompt: () => string[]; } /** Actions for ExtensionCommandContext (ctx.* in command handlers). */ diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 28d71cf40..d1b062343 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -540,11 +540,11 @@ async function buildSessionOptions( // System prompt if (resolvedSystemPrompt && resolvedAppendPrompt) { - options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`; + options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)]; } else if (resolvedSystemPrompt) { - options.systemPrompt = resolvedSystemPrompt; + options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)]; } else if (resolvedAppendPrompt) { - options.systemPrompt = defaultPrompt => `${defaultPrompt}\n\n${resolvedAppendPrompt}`; + options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt]; } // Tools diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 00660e422..5bc771986 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -602,7 +602,7 @@ async function runStage1Job(options: { const response = await completeSimple( model, { - systemPrompt: stageOneSystemTemplate, + systemPrompt: [stageOneSystemTemplate], messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }], }, { diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index ea22a74de..773eec205 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -635,7 +635,7 @@ export class AgentDashboard extends Container { modelRegistry, settings, model: selectedModel, - systemPrompt, + systemPrompt: [systemPrompt], hasUI: false, enableLsp: false, enableMCP: false, diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 6751b34ab..117664a81 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -89,7 +89,7 @@ export interface RpcSessionState { queuedMessageCount: number; todoPhases: TodoPhase[]; /** For session dump / export (plain-text parity with /dump). */ - systemPrompt?: string; + systemPrompt?: string[]; dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */ contextUsage?: ContextUsage; diff --git a/packages/coding-agent/src/modes/utils/context-usage.ts b/packages/coding-agent/src/modes/utils/context-usage.ts index 4fce2449f..fe4d3ee00 100644 --- a/packages/coding-agent/src/modes/utils/context-usage.ts +++ b/packages/coding-agent/src/modes/utils/context-usage.ts @@ -18,13 +18,13 @@ const CELL_FILLED_MESSAGES = "⛃"; const CELL_FREE = "⛶"; const CELL_BUFFER = "⛝"; -type CategoryId = "systemPrompt" | "systemTools" | "skills" | "messages"; +type CategoryId = "systemPrompt" | "systemContext" | "systemTools" | "skills" | "messages"; interface CategoryInfo { id: CategoryId; label: string; tokens: number; - color: "accent" | "warning" | "success" | "userMessageText"; + color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel"; glyph: string; } @@ -86,12 +86,19 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown // Tools = JSON tool schema sent separately on the wire // Skills = the skill list embedded in the system prompt // Messages = conversation messages - const systemPromptTextTokens = countTokens(session.systemPrompt); - const systemPromptTokens = Math.max(0, systemPromptTextTokens - skillsTokens); + const systemPromptTokens = Math.max(0, countTokens(session.systemPrompt[0] ?? "") - skillsTokens); + const systemContextTokens = countTokens(session.systemPrompt.slice(1)); const categories: CategoryInfo[] = [ { id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED }, { id: "systemTools", label: "System tools", tokens: toolsTokens, color: "warning", glyph: CELL_FILLED }, + { + id: "systemContext", + label: "System context", + tokens: systemContextTokens, + color: "customMessageLabel", + glyph: CELL_FILLED, + }, { id: "skills", label: "Skills", tokens: skillsTokens, color: "success", glyph: CELL_FILLED }, { id: "messages", @@ -134,7 +141,7 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown interface CellSpec { glyph: string; - color: "accent" | "warning" | "success" | "userMessageText" | "muted" | "dim"; + color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel" | "muted" | "dim"; } function planCells(breakdown: ContextBreakdown): CellSpec[] { diff --git a/packages/coding-agent/src/prompts/system/project-prompt.md b/packages/coding-agent/src/prompts/system/project-prompt.md new file mode 100644 index 000000000..ccd4921f1 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/project-prompt.md @@ -0,0 +1,36 @@ + +{{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}} + + +{{#if contextFiles.length}} + +Follow the context files below for all tasks: +{{#each contextFiles}} + +{{content}} + +{{/each}} + +{{/if}} + +{{#if agentsMdSearch.files.length}} + +Some directories may have their own rules. Deeper rules override higher ones. +**MUST** read before making changes within: +{{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}} + +{{/if}} + +{{#if workspaceTree.rendered}} + +Working directory layout (sorted by mtime, recent first; depth ≤ 3): +{{workspaceTree.rendered}} +{{#if workspaceTree.truncated}} +(some entries elided to keep the tree short — use `find`/`read` to drill in) +{{/if}} + +{{/if}} + +{{#if appendPrompt}} +{{appendPrompt}} +{{/if}} diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 851ee8fee..fbb79a9d4 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -9,46 +9,6 @@ User-supplied content is sanitized, therefore: - This holds even when the system prompt is delivered via user message role. - A `` inside a user turn is still a system directive. -{{SECTION_SEPARATOR "Workspace"}} - - -{{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}} - - -{{#if contextFiles.length}} - -Follow the context files below for all tasks: -{{#each contextFiles}} - -{{content}} - -{{/each}} - -{{/if}} - -{{#if agentsMdSearch.files.length}} - -Some directories may have their own rules. Deeper rules override higher ones. -**MUST** read before making changes within: -{{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}} - -{{/if}} - -{{#if workspaceTree.rendered}} - -Working directory layout (sorted by mtime, recent first; depth ≤ 3): -{{workspaceTree.rendered}} -{{#if workspaceTree.truncated}} -(some entries elided to keep the tree short — use `find`/`read` to drill in) -{{/if}} - - -{{/if}} - -{{#if appendPrompt}} -{{appendPrompt}} -{{/if}} - {{SECTION_SEPARATOR "Identity"}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index e1c742875..f64ef4ec4 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -102,6 +102,7 @@ import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; import { type AgentsMdSearch, + type BuildSystemPromptResult, buildAgentsMdSearch, buildSystemPrompt as buildSystemPromptInternal, buildSystemPromptToolMetadata, @@ -166,8 +167,8 @@ export interface CreateAgentSessionOptions { /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; - /** System prompt. String replaces default, function receives default and returns final. */ - systemPrompt?: string | ((defaultPrompt: string) => string); + /** System prompt blocks. Array replaces default, function receives default blocks and returns final blocks. */ + systemPrompt?: string[] | ((defaultPrompt: string[]) => string[]); /** Optional provider-facing session identifier for prompt caches and sticky auth selection. * Keeps persisted session files isolated while reusing provider-side caches. */ providerSessionId?: string; @@ -401,9 +402,12 @@ export interface BuildSystemPromptOptions { } /** - * Build the default system prompt. + * Build the default provider-facing system prompt blocks. + * + * The returned `systemPrompt` preserves the stable harness prompt and dynamic project context + * as separate entries so providers can cache prompt prefixes without concatenating blocks. */ -export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { +export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { return await buildSystemPromptInternal({ cwd: options.cwd, skills: options.skills, @@ -654,7 +658,7 @@ function buildMCPPromptCommands(manager: MCPManager): LoadedCustomCommand[] { * const { session } = await createAgentSession({ * model: myModel, * getApiKey: async () => Bun.env.MY_KEY, - * systemPrompt: 'You are helpful.', + * systemPrompt: ['You are helpful.'], * tools: codingTools({ cwd: getProjectDir() }), * skills: [], * sessionManager: SessionManager.inMemory(), @@ -1334,7 +1338,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const repeatToolDescriptions = settings.get("repeatToolDescriptions"); const eagerTasks = settings.get("task.eager"); const intentField = settings.get("tools.intentTracing") || $flag("PI_INTENT_TRACING") ? INTENT_FIELD : undefined; - const rebuildSystemPrompt = async (toolNames: string[], tools: Map): Promise => { + const rebuildSystemPrompt = async ( + toolNames: string[], + tools: Map, + ): Promise => { toolContextStore.setToolNames(toolNames); const discoverableMCPTools = mcpDiscoveryEnabled ? collectDiscoverableMCPTools(tools.values()) : []; const discoverableMCPSummary = summarizeDiscoverableMCPTools(discoverableMCPTools); @@ -1390,29 +1397,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (options.systemPrompt === undefined) { return defaultPrompt; } - if (typeof options.systemPrompt === "string") { - return await buildSystemPromptInternal({ - cwd, - skills, - contextFiles, - tools: promptTools, - toolNames, - rules: rulebookRules, - alwaysApplyRules, - skillsSettings: settings.getGroup("skills"), - customPrompt: options.systemPrompt, - appendSystemPrompt: appendPrompt, - repeatToolDescriptions, - intentField, - mcpDiscoveryMode: hasDiscoverableMCPTools, - mcpDiscoveryServerSummaries: discoverableMCPSummary.servers.map(formatDiscoverableMCPToolServerSummary), - eagerTasks, - secretsEnabled, - agentsMdSearch: agentsMdSearchPromise, - workspaceTree: workspaceTreePromise, - }); + if (Array.isArray(options.systemPrompt)) { + return { systemPrompt: options.systemPrompt }; } - return options.systemPrompt(defaultPrompt); + return { + systemPrompt: options.systemPrompt(defaultPrompt.systemPrompt), + }; }; const toolNamesFromRegistry = Array.from(toolRegistry.keys()); @@ -1478,7 +1468,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } - const systemPrompt = await logger.time("buildSystemPrompt", rebuildSystemPrompt, initialToolNames, toolRegistry); + const { systemPrompt } = await logger.time( + "buildSystemPrompt", + rebuildSystemPrompt, + initialToolNames, + toolRegistry, + ); const promptTemplates = await promptTemplatesPromise; toolSession.promptTemplates = promptTemplates; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6324a9e6d..9e0f23382 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -245,8 +245,8 @@ export interface AgentSessionConfig { onResponse?: SimpleStreamOptions["onResponse"]; /** Current session message-to-LLM conversion pipeline */ convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; - /** System prompt builder that can consider tool availability */ - rebuildSystemPrompt?: (toolNames: string[], tools: Map) => Promise; + /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */ + rebuildSystemPrompt?: (toolNames: string[], tools: Map) => Promise<{ systemPrompt: string[] }>; /** * Optional accessor for live MCP server instructions. Read by the session's * `rebuildSystemPrompt`-skip optimization to detect server-side instruction @@ -520,9 +520,11 @@ export class AgentSession { #onPayload: SimpleStreamOptions["onPayload"] | undefined; #onResponse: SimpleStreamOptions["onResponse"] | undefined; #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; - #rebuildSystemPrompt: ((toolNames: string[], tools: Map) => Promise) | undefined; + #rebuildSystemPrompt: + | ((toolNames: string[], tools: Map) => Promise<{ systemPrompt: string[] }>) + | undefined; #getMcpServerInstructions: (() => Map | undefined) | undefined; - #baseSystemPrompt: string; + #baseSystemPrompt: string[]; /** * Signature of the (toolNames, tool descriptions) tuple passed to the most * recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds @@ -2083,8 +2085,8 @@ export class AgentSession { getLastAssistantMessage(): AssistantMessage | undefined { return this.#findLastAssistantMessage(); } - /** Current effective system prompt (includes any per-turn extension modifications) */ - get systemPrompt(): string { + /** Current effective system prompt blocks (includes any per-turn extension modifications) */ + get systemPrompt(): string[] { return this.agent.state.systemPrompt; } @@ -2281,7 +2283,8 @@ export class AgentSession { if (this.#rebuildSystemPrompt) { const signature = this.#computeAppliedToolSignature(validToolNames, tools); if (signature !== this.#lastAppliedToolSignature) { - this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry); + const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry); + this.#baseSystemPrompt = built.systemPrompt; this.agent.setSystemPrompt(this.#baseSystemPrompt); this.#lastAppliedToolSignature = signature; } @@ -2324,7 +2327,8 @@ export class AgentSession { async refreshBaseSystemPrompt(): Promise { if (!this.#rebuildSystemPrompt) return; const activeToolNames = this.getActiveToolNames(); - this.#baseSystemPrompt = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); + const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); + this.#baseSystemPrompt = built.systemPrompt; this.agent.setSystemPrompt(this.#baseSystemPrompt); // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with // the same tool set does not re-rebuild on top of the explicit refresh we @@ -2335,14 +2339,14 @@ export class AgentSession { this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools); } - async #buildSystemPromptForAgentStart(promptText: string): Promise { + async #buildSystemPromptForAgentStart(promptText: string): Promise { const backend = resolveMemoryBackend(this.settings); if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt; try { const injected = await backend.beforeAgentStartPrompt(this, promptText); if (!injected) return this.#baseSystemPrompt; - return `${this.#baseSystemPrompt}\n\n${injected}`; + return [...this.#baseSystemPrompt, injected]; } catch (err) { logger.debug("Memory backend beforeAgentStartPrompt failed", { backend: backend.id, @@ -4215,7 +4219,11 @@ export class AgentSession { apiKey, customInstructions, compactionAbortController.signal, - { promptOverride: hookPrompt, extraContext: hookContext, remoteInstructions: this.#baseSystemPrompt }, + { + promptOverride: hookPrompt, + extraContext: hookContext, + remoteInstructions: this.#baseSystemPrompt.join("\n\n"), + }, ); summary = result.summary; shortSummary = result.shortSummary; @@ -5328,7 +5336,7 @@ export class AgentSession { compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, { promptOverride: hookPrompt, extraContext: hookContext, - remoteInstructions: this.#baseSystemPrompt, + remoteInstructions: this.#baseSystemPrompt.join("\n\n"), initiatorOverride: "agent", }); break; diff --git a/packages/coding-agent/src/session/compaction/branch-summarization.ts b/packages/coding-agent/src/session/compaction/branch-summarization.ts index b590d3ac6..dc920c63e 100644 --- a/packages/coding-agent/src/session/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/session/compaction/branch-summarization.ts @@ -290,7 +290,7 @@ export async function generateBranchSummary( // Call LLM for summarization const response = await completeSimple( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages }, { apiKey, signal, maxTokens: 2048 }, ); diff --git a/packages/coding-agent/src/session/compaction/compaction.ts b/packages/coding-agent/src/session/compaction/compaction.ts index 58b69be08..8e3768028 100644 --- a/packages/coding-agent/src/session/compaction/compaction.ts +++ b/packages/coding-agent/src/session/compaction/compaction.ts @@ -1019,7 +1019,7 @@ export async function generateSummary( const response = await completeSimple( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages }, { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride }, ); @@ -1066,7 +1066,7 @@ async function generateShortSummary( const response = await completeSimple( model, { - systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, + systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }], }, { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride }, @@ -1386,7 +1386,7 @@ async function generateTurnPrefixSummary( const response = await completeSimple( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages }, { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride }, ); diff --git a/packages/coding-agent/src/session/session-dump-format.ts b/packages/coding-agent/src/session/session-dump-format.ts index 72ad9b275..67ab3dc92 100644 --- a/packages/coding-agent/src/session/session-dump-format.ts +++ b/packages/coding-agent/src/session/session-dump-format.ts @@ -25,7 +25,7 @@ export interface SessionDumpToolInfo { export interface FormatSessionDumpTextOptions { messages: readonly AgentMessage[]; - systemPrompt?: string | null; + systemPrompt?: readonly string[] | null; model?: Model | null; thinkingLevel?: ThinkingLevel | string | null; tools?: readonly SessionDumpToolInfo[]; @@ -64,11 +64,16 @@ function formatArgsAsXml(args: Record, indent = "\t"): string { export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string { const lines: string[] = []; - const systemPrompt = options.systemPrompt; - if (systemPrompt) { + const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? []; + if (systemPrompt.length > 0) { lines.push("## System Prompt\n"); - lines.push(systemPrompt); - lines.push("\n"); + for (let index = 0; index < systemPrompt.length; index++) { + if (systemPrompt.length > 1) { + lines.push(`### System Prompt ${index + 1}\n`); + } + lines.push(systemPrompt[index]); + lines.push("\n"); + } } const model = options.model; diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 42d125df3..fcdfab8fe 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -13,6 +13,7 @@ import type { SkillsSettings } from "./config/settings"; import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery"; import { loadSkills, type Skill } from "./extensibility/skills"; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; +import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" }; import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" }; import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; @@ -414,10 +415,16 @@ export interface BuildSystemPromptOptions { workspaceTree?: WorkspaceTree | Promise; } +/** Result of building provider-facing system prompt messages. */ +export interface BuildSystemPromptResult { + /** Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks. */ + systemPrompt: string[]; +} + /** Build the system prompt with tools, guidelines, and context */ -export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { +export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { if ($env.NULL_PROMPT === "true") { - return ""; + return { systemPrompt: [] }; } const { @@ -618,5 +625,11 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): rendered += `\n\n\nThe \`${reportToolIssueToolName}\` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call \`${reportToolIssueToolName}\` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable.\n`; } - return rendered; + const systemPrompt = [rendered]; + const projectPrompt = resolvedCustomPrompt ? "" : prompt.render(projectPromptTemplate, data).trim(); + if (projectPrompt) { + systemPrompt.push(projectPrompt); + } + + return { systemPrompt }; } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 0a0674587..f5fccd1c2 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -967,9 +967,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise + systemPrompt: defaultPrompt => [ prompt.render(subagentSystemPromptTemplate, { - base: defaultPrompt, + base: defaultPrompt.join("\n\n"), agent: agent.systemPrompt, worktree: worktree ?? "", outputSchema: normalizedOutputSchema, @@ -977,6 +977,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath); if (tree.truncated) { - resultBuilder.limits({ resultLimit: true }); + resultBuilder.limits({ resultLimit: 1 }); } if (truncation.truncated) { resultBuilder.truncation(truncation, { direction: "head" }); diff --git a/packages/coding-agent/src/utils/commit-message-generator.ts b/packages/coding-agent/src/utils/commit-message-generator.ts index 218226fde..4746c1658 100644 --- a/packages/coding-agent/src/utils/commit-message-generator.ts +++ b/packages/coding-agent/src/utils/commit-message-generator.ts @@ -102,7 +102,7 @@ export async function generateCommitMessage( const response = await completeSimple( candidate.model, { - systemPrompt: COMMIT_SYSTEM_PROMPT, + systemPrompt: [COMMIT_SYSTEM_PROMPT], messages: [{ role: "user", content: userMessage, timestamp: Date.now() }], }, { apiKey, maxTokens: 60, reasoning: toReasoningEffort(candidate.thinkingLevel) }, diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index d29c1c664..c8c07856d 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -81,7 +81,7 @@ ${truncatedMessage} const response = await completeSimple( model, { - systemPrompt: request.systemPrompt, + systemPrompt: [request.systemPrompt], messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }], }, { diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index 2b0b7bf82..c24b0e0af 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -63,7 +63,7 @@ function buildSystemBlocks( const includeClaudeCode = !model.startsWith("claude-3-5-haiku"); const extraInstructions = auth.isOAuth ? ["You are a helpful AI assistant with web search capabilities."] : []; - return buildAnthropicSystemBlocks(systemPrompt, { + return buildAnthropicSystemBlocks(systemPrompt ? [systemPrompt] : undefined, { includeClaudeCodeInstruction: includeClaudeCode, extraInstructions, cacheControl: { type: "ephemeral" }, diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index d12d2181f..977a44fe8 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -98,7 +98,7 @@ describe("AgentSession auto-compaction queue resume", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-bash-detach.test.ts b/packages/coding-agent/test/agent-session-bash-detach.test.ts index 92c733a02..a568f1a5c 100644 --- a/packages/coding-agent/test/agent-session-bash-detach.test.ts +++ b/packages/coding-agent/test/agent-session-bash-detach.test.ts @@ -205,7 +205,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e) getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [bashTool as unknown as AgentTool], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts b/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts index ae8cf6365..1bae6817f 100644 --- a/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts +++ b/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts @@ -63,7 +63,7 @@ describe("AgentSession before_agent_start attribution fallback", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index d19944ca3..ba955388d 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -60,7 +60,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession branching", () => getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { model, - systemPrompt: "You are a helpful assistant. Be extremely concise, reply with just a few words.", + systemPrompt: ["You are a helpful assistant. Be extremely concise, reply with just a few words."], tools, }, }); diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 7d571dcaa..071139525 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -64,7 +64,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession compaction e2e", getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { model, - systemPrompt: "You are a helpful assistant. Be concise.", + systemPrompt: ["You are a helpful assistant. Be concise."], tools, }, }); diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index a87cc40fc..a33cabda6 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -56,7 +56,7 @@ describe("AgentSession concurrent prompt guard", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], }, streamFn: (_model, _context, options) => { @@ -165,7 +165,7 @@ describe("AgentSession concurrent prompt guard", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], }, convertToLlm, @@ -239,7 +239,7 @@ describe("AgentSession concurrent prompt guard", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], }, streamFn: () => { @@ -384,7 +384,7 @@ describe("AgentSession TTSR resume gate", () => { const agent = new Agent({ getApiKey: () => "test-key", - initialState: { model, systemPrompt: "Test", tools: [] }, + initialState: { model, systemPrompt: ["Test"], tools: [] }, streamFn: (_model, _context, options) => { streamCallCount++; const stream = new MockAssistantStream(); @@ -445,7 +445,7 @@ describe("AgentSession TTSR resume gate", () => { const agent = new Agent({ getApiKey: () => "test-key", - initialState: { model, systemPrompt: "Test", tools: [] }, + initialState: { model, systemPrompt: ["Test"], tools: [] }, streamFn: (_model, _context, _options) => { streamCallCount++; const stream = new MockAssistantStream(); @@ -517,7 +517,7 @@ describe("AgentSession TTSR resume gate", () => { const agent = new Agent({ getApiKey: () => "test-key", - initialState: { model, systemPrompt: "Test", tools: [] }, + initialState: { model, systemPrompt: ["Test"], tools: [] }, streamFn: (_model, _context, options) => { const stream = new MockAssistantStream(); const signal = options?.signal; @@ -633,7 +633,7 @@ describe("AgentSession TTSR resume gate", () => { const agent = new Agent({ getApiKey: () => "test-key", - initialState: { model, systemPrompt: "Test", tools: [mockTool] }, + initialState: { model, systemPrompt: ["Test"], tools: [mockTool] }, streamFn: (_model, _context, options) => { streamCallCount++; const stream = new MockAssistantStream(); @@ -743,7 +743,7 @@ describe("AgentSession TTSR resume gate", () => { const agent = new Agent({ getApiKey: () => "test-key", - initialState: { model: sparkModel, systemPrompt: "Test", tools: [] }, + initialState: { model: sparkModel, systemPrompt: ["Test"], tools: [] }, streamFn: () => { streamCallCount++; const stream = new MockAssistantStream(); diff --git a/packages/coding-agent/test/agent-session-context-promotion.test.ts b/packages/coding-agent/test/agent-session-context-promotion.test.ts index bf0ab271b..2bd4b07cd 100644 --- a/packages/coding-agent/test/agent-session-context-promotion.test.ts +++ b/packages/coding-agent/test/agent-session-context-promotion.test.ts @@ -106,7 +106,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: sparkModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -151,7 +151,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: sparkModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -187,7 +187,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: codexModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -224,7 +224,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: nonCodexModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -259,7 +259,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: codexModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -300,7 +300,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: codexModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -346,7 +346,7 @@ describe("AgentSession context promotion", () => { const agent = new Agent({ initialState: { model: sparkModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index 86772e6bc..4726118a8 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -131,7 +131,7 @@ describe("AgentSession eager todo enforcement", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [todoWriteTool, mockBashTool], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts index 55f4c4d6c..0ae7ecad7 100644 --- a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts +++ b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts @@ -48,7 +48,7 @@ beforeEach(async () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [bashTool, writeTool], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index aa83c9941..dfaf95f98 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -38,7 +38,7 @@ describe("AgentSession handoff", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -465,7 +465,7 @@ describe("AgentSession handoff", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -572,7 +572,7 @@ describe("AgentSession handoff", () => { modelRegistry, ); const emitBeforeAgentStart = vi.spyOn(extensionRunner, "emitBeforeAgentStart").mockResolvedValueOnce({ - systemPrompt: "Hook override", + systemPrompt: ["Hook override"], }); vi.spyOn(extensionRunner, "emit").mockResolvedValue(undefined); @@ -582,12 +582,12 @@ describe("AgentSession handoff", () => { getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, streamFn: (_model, context) => { - observedSystemPrompts.push(context.systemPrompt ?? ""); + observedSystemPrompts.push(context.systemPrompt?.join("\n\n") ?? ""); streamCallCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { diff --git a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts index f8fb13557..d75867eb3 100644 --- a/packages/coding-agent/test/agent-session-mcp-discovery.test.ts +++ b/packages/coding-agent/test/agent-session-mcp-discovery.test.ts @@ -105,7 +105,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -117,7 +117,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -150,7 +152,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, docsSearchTool], messages: [], }, @@ -162,7 +164,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: false, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -172,7 +176,7 @@ describe("AgentSession MCP discovery", () => { expect(session.getSelectedMCPToolNames()).toEqual([]); expect(session.getActiveToolNames()).toEqual(["read"]); - expect(session.systemPrompt).toBe("tools:read"); + expect(session.systemPrompt).toEqual(["tools:read"]); }); it("keeps manually deactivated MCP tools off after refresh in non-discovery sessions", async () => { @@ -190,7 +194,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, docsSearchTool], messages: [], }, @@ -202,7 +206,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: false, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -219,7 +225,7 @@ describe("AgentSession MCP discovery", () => { expect(session.getSelectedMCPToolNames()).toEqual([]); expect(session.getActiveToolNames()).toEqual(["read"]); - expect(session.systemPrompt).toBe("tools:read"); + expect(session.systemPrompt).toEqual(["tools:read"]); }); it("preserves directly activated MCP tools across refreshes in discovery mode", async () => { @@ -237,7 +243,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -249,7 +255,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -283,7 +291,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -295,7 +303,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -308,12 +318,12 @@ describe("AgentSession MCP discovery", () => { await session.activateDiscoveredMCPTools(["mcp__docs_search"]); expect(session.getSelectedMCPToolNames()).toEqual(["mcp__docs_search"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__docs_search"]); - expect(session.systemPrompt).toBe("tools:read,mcp__docs_search"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__docs_search"]); await session.activateDiscoveredMCPTools(["mcp__slack_send_message"]); expect(session.getSelectedMCPToolNames()).toEqual(["mcp__docs_search", "mcp__slack_send_message"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__docs_search", "mcp__slack_send_message"]); - expect(session.systemPrompt).toBe("tools:read,mcp__docs_search,mcp__slack_send_message"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__docs_search,mcp__slack_send_message"]); }); it("reapplies default MCP server baselines when refreshed tools reconnect", async () => { const readTool = createBasicTool("read", "Read"); @@ -326,7 +336,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -339,7 +349,9 @@ describe("AgentSession MCP discovery", () => { toolRegistry, mcpDiscoveryEnabled: true, defaultSelectedMCPServerNames: ["slack"], - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -356,7 +368,7 @@ describe("AgentSession MCP discovery", () => { expect(session.getSelectedMCPToolNames()).toEqual(["mcp__slack_send_message"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__slack_send_message"]); - expect(session.systemPrompt).toBe("tools:read,mcp__slack_send_message"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__slack_send_message"]); expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual(["mcp__slack_send_message"]); }); @@ -371,7 +383,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -383,7 +395,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -404,7 +418,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -416,7 +430,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry: new Map([[readTool.name, readTool]]), mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -440,7 +456,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: sessionManager.buildSessionContext().messages, }, @@ -452,7 +468,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -464,7 +482,7 @@ describe("AgentSession MCP discovery", () => { expect(result.cancelled).toBe(false); expect(session.getSelectedMCPToolNames()).toEqual([]); expect(session.getActiveToolNames()).toEqual(["read"]); - expect(session.systemPrompt).toBe("tools:read"); + expect(session.systemPrompt).toEqual(["tools:read"]); }); it("restores MCP discovery selections when navigating to a branch without them", async () => { @@ -483,7 +501,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: sessionManager.buildSessionContext().messages, }, @@ -495,7 +513,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -507,7 +527,7 @@ describe("AgentSession MCP discovery", () => { expect(result.cancelled).toBe(false); expect(session.getSelectedMCPToolNames()).toEqual([]); expect(session.getActiveToolNames()).toEqual(["read"]); - expect(session.systemPrompt).toBe("tools:read"); + expect(session.systemPrompt).toEqual(["tools:read"]); }); it("preserves explicit MCP baseline when branching into older history without persisted selection", async () => { @@ -526,7 +546,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, docsSearchTool], messages: sessionManager.buildSessionContext().messages, }, @@ -540,7 +560,9 @@ describe("AgentSession MCP discovery", () => { mcpDiscoveryEnabled: true, initialSelectedMCPToolNames: ["mcp__docs_search"], defaultSelectedMCPToolNames: ["mcp__docs_search"], - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -549,7 +571,7 @@ describe("AgentSession MCP discovery", () => { expect(result.cancelled).toBe(false); expect(session.getSelectedMCPToolNames()).toEqual(["mcp__docs_search"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__docs_search"]); - expect(session.systemPrompt).toBe("tools:read,mcp__docs_search"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__docs_search"]); }); it("preserves explicit MCP baseline when navigating into older history without persisted selection", async () => { @@ -573,7 +595,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, docsSearchTool], messages: sessionManager.buildSessionContext().messages, }, @@ -587,7 +609,9 @@ describe("AgentSession MCP discovery", () => { mcpDiscoveryEnabled: true, initialSelectedMCPToolNames: ["mcp__docs_search"], defaultSelectedMCPToolNames: ["mcp__docs_search"], - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -596,7 +620,7 @@ describe("AgentSession MCP discovery", () => { expect(result.cancelled).toBe(false); expect(session.getSelectedMCPToolNames()).toEqual(["mcp__docs_search"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__docs_search"]); - expect(session.systemPrompt).toBe("tools:read,mcp__docs_search"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__docs_search"]); }); it("restores session defaults in memory across session switches without rewriting sessions missing persisted metadata", async () => { @@ -635,7 +659,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: reasoningModel, - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, docsSearchTool], messages: sessionManager.buildSessionContext().messages, }, @@ -653,7 +677,9 @@ describe("AgentSession MCP discovery", () => { mcpDiscoveryEnabled: true, initialSelectedMCPToolNames: ["mcp__docs_search"], defaultSelectedMCPToolNames: ["mcp__docs_search"], - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -676,7 +702,7 @@ describe("AgentSession MCP discovery", () => { expect(session.serviceTier).toBe("priority"); expect(session.getSelectedMCPToolNames()).toEqual([]); expect(session.getActiveToolNames()).toEqual(["read"]); - expect(session.systemPrompt).toBe("tools:read"); + expect(session.systemPrompt).toEqual(["tools:read"]); expect(fs.readFileSync(olderSessionFile!, "utf8")).toBe(olderSessionBeforeSwitch); expect(fs.statSync(olderSessionFile!).mtimeMs).toBe(olderSessionMtimeBeforeSwitch); @@ -686,7 +712,7 @@ describe("AgentSession MCP discovery", () => { expect(session.serviceTier).toBe("flex"); expect(session.getSelectedMCPToolNames()).toEqual(["mcp__docs_search"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__docs_search"]); - expect(session.systemPrompt).toBe("tools:read,mcp__docs_search"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__docs_search"]); expect(fs.readFileSync(originalSessionFile!, "utf8")).toBe(originalSessionBeforeSwitch); expect(fs.statSync(originalSessionFile!).mtimeMs).toBe(originalSessionMtimeBeforeSwitch); }); @@ -702,7 +728,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, docsSearchTool], messages: [], }, @@ -716,7 +742,9 @@ describe("AgentSession MCP discovery", () => { mcpDiscoveryEnabled: true, initialSelectedMCPToolNames: ["mcp__docs_search", "mcp__slack_send_message"], defaultSelectedMCPToolNames: ["mcp__docs_search", "mcp__slack_send_message"], - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -738,7 +766,7 @@ describe("AgentSession MCP discovery", () => { expect(session.getSelectedMCPToolNames()).toEqual(["mcp__docs_search", "mcp__slack_send_message"]); expect(session.getActiveToolNames()).toEqual(["read", "mcp__docs_search", "mcp__slack_send_message"]); - expect(session.systemPrompt).toBe("tools:read,mcp__docs_search,mcp__slack_send_message"); + expect(session.systemPrompt).toEqual(["tools:read,mcp__docs_search,mcp__slack_send_message"]); expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual([ "mcp__docs_search", "mcp__slack_send_message", @@ -760,7 +788,7 @@ describe("AgentSession MCP discovery", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool], messages: [], }, @@ -772,7 +800,9 @@ describe("AgentSession MCP discovery", () => { modelRegistry: {} as never, toolRegistry, mcpDiscoveryEnabled: true, - rebuildSystemPrompt: async toolNames => `tools:${toolNames.join(",")}`, + rebuildSystemPrompt: async toolNames => ({ + systemPrompt: [`tools:${toolNames.join(",")}`], + }), }); sessions.push(session); @@ -784,6 +814,6 @@ describe("AgentSession MCP discovery", () => { expect(session.getSelectedMCPToolNames()).toEqual([]); expect(session.getActiveToolNames()).toEqual(["read"]); - expect(session.systemPrompt).toBe("tools:read"); + expect(session.systemPrompt).toEqual(["tools:read"]); }); }); diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 6c1f7c8d0..a1603f6be 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -8,7 +8,7 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage function createAgent(): Agent { return new Agent({ initialState: { - systemPrompt: "system prompt", + systemPrompt: ["system prompt"], messages: [], tools: [], }, diff --git a/packages/coding-agent/test/agent-session-new-session-todos.test.ts b/packages/coding-agent/test/agent-session-new-session-todos.test.ts index 0560b1b68..5340e7bf7 100644 --- a/packages/coding-agent/test/agent-session-new-session-todos.test.ts +++ b/packages/coding-agent/test/agent-session-new-session-todos.test.ts @@ -52,7 +52,7 @@ describe("AgentSession newSession clears todo artifacts", () => { getApiKey: () => "test", initialState: { model, - systemPrompt: "test", + systemPrompt: ["test"], tools: [new TodoWriteTool(toolSession)], }, }); diff --git a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts index 9b2121dc4..4b96ae697 100644 --- a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts +++ b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts @@ -40,7 +40,7 @@ describe("AgentSession resolve reminder", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index d9c2bd000..654139c4f 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -79,7 +79,7 @@ function createFallbackAgent(primaryModel: Model, requestedModels: string[]): Ag getApiKey: provider => `${provider}-test-key`, initialState: { model: primaryModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -150,7 +150,7 @@ describe("AgentSession retry fallback", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model: primaryModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -278,7 +278,7 @@ describe("AgentSession retry fallback", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -361,7 +361,7 @@ describe("AgentSession retry fallback", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -443,7 +443,7 @@ describe("AgentSession retry fallback", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -527,7 +527,7 @@ describe("AgentSession retry fallback", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model: primaryModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, @@ -596,7 +596,7 @@ describe("AgentSession retry fallback", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 7aee5cdf6..3a17947e5 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -44,7 +44,7 @@ describe("AgentSession role model thinking behavior", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], thinkingLevel: options.initialThinkingLevel, @@ -179,7 +179,7 @@ describe("AgentSession role model thinking behavior", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], thinkingLevel: undefined, @@ -209,7 +209,7 @@ describe("AgentSession role model thinking behavior", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], thinkingLevel: Effort.High, diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 5a8d09e83..3d11bdaed 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -84,7 +84,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { const agent = new Agent({ initialState: { model: createModel(), - systemPrompt: "initial", + systemPrompt: ["initial"], tools: [readTool, initialMcp as unknown as AgentTool], messages: [], }, @@ -95,7 +95,9 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { settings: Settings.isolated({ "compaction.enabled": false }), modelRegistry: {} as never, toolRegistry, - rebuildSystemPrompt, + rebuildSystemPrompt: async (toolNames, _tools) => ({ + systemPrompt: [await rebuildSystemPrompt(toolNames)], + }), mcpDiscoveryEnabled: options.mcpDiscoveryEnabled, getMcpServerInstructions: options.getMcpServerInstructions, }); diff --git a/packages/coding-agent/test/agent-session-tree-navigation.test.ts b/packages/coding-agent/test/agent-session-tree-navigation.test.ts index 00aec5bce..4fe0ae77c 100644 --- a/packages/coding-agent/test/agent-session-tree-navigation.test.ts +++ b/packages/coding-agent/test/agent-session-tree-navigation.test.ts @@ -16,7 +16,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession tree navigation e beforeEach(async () => { ctx = await createTestSession({ - systemPrompt: "You are a helpful assistant. Reply with just a few words.", + systemPrompt: ["You are a helpful assistant. Reply with just a few words."], settingsOverrides: { compaction: { keepRecentTokens: 1 } }, }); }); @@ -275,7 +275,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession tree navigation - beforeEach(async () => { ctx = await createTestSession({ - systemPrompt: "You are a helpful assistant. Reply with just a few words.", + systemPrompt: ["You are a helpful assistant. Reply with just a few words."], }); }); diff --git a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts index 120e27a60..dcfe78d7b 100644 --- a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts +++ b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts @@ -42,7 +42,7 @@ describe("AgentSession user shortcut hooks", () => { const agent = new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 9936a1bfb..900420d0d 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -97,7 +97,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Compaction hooks", () => { getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { model, - systemPrompt: "You are a helpful assistant. Be concise.", + systemPrompt: ["You are a helpful assistant. Be concise."], tools, }, }); diff --git a/packages/coding-agent/test/compaction-thinking-model.test.ts b/packages/coding-agent/test/compaction-thinking-model.test.ts index 95dcd7929..3412e2046 100644 --- a/packages/coding-agent/test/compaction-thinking-model.test.ts +++ b/packages/coding-agent/test/compaction-thinking-model.test.ts @@ -70,7 +70,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { model, - systemPrompt: "You are a helpful assistant. Be concise.", + systemPrompt: ["You are a helpful assistant. Be concise."], tools, thinkingLevel, }, @@ -176,7 +176,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { model, - systemPrompt: "You are a helpful assistant. Be concise.", + systemPrompt: ["You are a helpful assistant. Be concise."], tools, thinkingLevel, }, diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 4e505b17e..41dc2fdee 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -647,7 +647,7 @@ describe("ExtensionRunner", () => { shutdown: () => {}, getContextUsage: () => undefined, compact: async () => {}, - getSystemPrompt: () => "", + getSystemPrompt: () => [], }, ); diff --git a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts index ca8bb139c..f98b3d510 100644 --- a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts @@ -51,7 +51,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { agent: new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index f4cb59998..f14359b31 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -37,7 +37,7 @@ describe("InteractiveMode plan review rendering", () => { agent: new Agent({ initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/issue-775-repro.test.ts b/packages/coding-agent/test/issue-775-repro.test.ts index e4e52dc13..5f4dff837 100644 --- a/packages/coding-agent/test/issue-775-repro.test.ts +++ b/packages/coding-agent/test/issue-775-repro.test.ts @@ -44,7 +44,7 @@ describe("issue #775: per-model defaultLevel", () => { const agent = new Agent({ initialState: { model: initialModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], thinkingLevel: Effort.Low, diff --git a/packages/coding-agent/test/issue-816-repro.test.ts b/packages/coding-agent/test/issue-816-repro.test.ts index 7ac0a5eec..7d12c86dd 100644 --- a/packages/coding-agent/test/issue-816-repro.test.ts +++ b/packages/coding-agent/test/issue-816-repro.test.ts @@ -34,7 +34,7 @@ describe("issue #816 — plan mode pendingModelSwitch leak", () => { agent: new Agent({ initialState: { model: defaultModel, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [], messages: [], }, diff --git a/packages/coding-agent/test/plan-mode-thinking-level.test.ts b/packages/coding-agent/test/plan-mode-thinking-level.test.ts index 12edc3d77..b5eee3bf3 100644 --- a/packages/coding-agent/test/plan-mode-thinking-level.test.ts +++ b/packages/coding-agent/test/plan-mode-thinking-level.test.ts @@ -43,7 +43,7 @@ describe("plan mode thinking level", () => { session = new AgentSession({ agent: new Agent({ - initialState: { model: sonnet, systemPrompt: "Test", tools: [], messages: [] }, + initialState: { model: sonnet, systemPrompt: ["Test"], tools: [], messages: [] }, }), sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ modelRoles }), diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index e9196d170..85a9ca284 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -91,7 +91,7 @@ async function createSession( getApiKey: () => "test-key", initialState: { model, - systemPrompt: "Test", + systemPrompt: ["Test"], tools: [tool], }, streamFn, diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index 258a05e80..f19497abd 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -42,7 +42,7 @@ describe("SYSTEM.md prompt assembly", () => { agentDir: projectDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), - systemPrompt, + systemPrompt: [systemPrompt], disableExtensionDiscovery: true, skills: [], contextFiles: [], @@ -75,7 +75,7 @@ describe("SYSTEM.md prompt assembly", () => { const nearPath = path.join(tempDir, "near", "CLAUDE.md"); const sharedContent = "Shared context instructions"; - const prompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: tempDir, customPrompt: "Base prompt", contextFiles: [ @@ -87,10 +87,11 @@ describe("SYSTEM.md prompt assembly", () => { toolNames: [], }); - const matches = prompt.match(new RegExp(escapeRegExp(sharedContent), "g")) ?? []; + const promptText = systemPrompt.join("\n\n"); + const matches = promptText.match(new RegExp(escapeRegExp(sharedContent), "g")) ?? []; expect(matches).toHaveLength(1); - expect(prompt).not.toContain(``); - expect(prompt).toContain(``); + expect(promptText).not.toContain(``); + expect(promptText).toContain(``); }); it("drops identical discovered context entries and keeps the closest copy", async () => { @@ -113,7 +114,7 @@ describe("SYSTEM.md prompt assembly", () => { const farPath = path.join(tempDir, "far", "AGENTS.md"); const nearPath = path.join(tempDir, "near", "CLAUDE.md"); - const prompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: tempDir, customPrompt: "Base prompt", contextFiles: [ @@ -124,8 +125,9 @@ describe("SYSTEM.md prompt assembly", () => { rules: [], toolNames: [], }); + const promptText = systemPrompt.join("\n\n"); - expect(prompt).toContain("Root context instructions"); - expect(prompt).toContain("Near context instructions"); + expect(promptText).toContain("Root context instructions"); + expect(promptText).toContain("Near context instructions"); }); }); diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index 7b16988f8..8cb046ff2 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -203,9 +203,9 @@ describe("system Handlebars prompt templates", () => { expect(rendered).toContain("call `search_tool_bm25` before concluding no such tool exists"); }); - test("buildSystemPrompt renders workspace tree after directory context", async () => { + test("buildSystemPrompt renders workspace tree after directory context in project prompt", async () => { await withTempDir(async dir => { - const systemPrompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: dir, contextFiles: [], skills: [], @@ -225,10 +225,12 @@ describe("system Handlebars prompt templates", () => { }, }); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain("Working directory layout (sorted by mtime, recent first; depth ≤ 3):"); - expect(systemPrompt).toContain("(some entries elided to keep the tree short"); - expect(systemPrompt.indexOf("")).toBeLessThan(systemPrompt.indexOf("")); + const projectPrompt = systemPrompt[1] ?? ""; + + expect(projectPrompt).toContain(""); + expect(projectPrompt).toContain("Working directory layout (sorted by mtime, recent first; depth ≤ 3):"); + expect(projectPrompt).toContain("(some entries elided to keep the tree short"); + expect(projectPrompt.indexOf("")).toBeLessThan(projectPrompt.indexOf("")); }); }); @@ -244,7 +246,7 @@ describe("system Handlebars prompt templates", () => { ["Project instructions", "", duplicateRule, "", "Trailing note"].join("\n"), ); - const prompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: dir, contextFiles: [], skills: [], @@ -257,6 +259,8 @@ describe("system Handlebars prompt templates", () => { ], }); + const prompt = systemPrompt.join("\n\n"); + expect(countOccurrences(prompt, "Use static imports.")).toBe(1); expect(countOccurrences(prompt, "Do not use dynamic loading.")).toBe(1); expect(countOccurrences(prompt, distinctRule)).toBe(1); @@ -267,7 +271,7 @@ describe("system Handlebars prompt templates", () => { const duplicateRule = ["Keep functions small.", "", "Extract shared helpers on the second use."].join("\n"); const distinctRule = "Surface failures explicitly to callers."; - const prompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: os.tmpdir(), contextFiles: [], skills: [], @@ -280,6 +284,8 @@ describe("system Handlebars prompt templates", () => { ], }); + const prompt = systemPrompt.join("\n\n"); + expect(countOccurrences(prompt, "Keep functions small.")).toBe(1); expect(countOccurrences(prompt, "Extract shared helpers on the second use.")).toBe(1); expect(countOccurrences(prompt, distinctRule)).toBe(1); @@ -301,7 +307,7 @@ describe("system Handlebars prompt templates", () => { }); test("buildSystemPrompt references overridden tool wire names", async () => { - const systemPrompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: os.tmpdir(), contextFiles: [], skills: [], @@ -318,10 +324,12 @@ describe("system Handlebars prompt templates", () => { ]), }); - expect(systemPrompt).toContain("Edit: `apply_patch`"); - expect(systemPrompt).toContain("`read`, `search`, `find`, `apply_patch`, `lsp`"); - expect(systemPrompt).toContain("Use `apply_patch` for surgical text changes"); - expect(systemPrompt).not.toContain("Edit: `edit`"); + const promptText = systemPrompt.join("\n\n"); + + expect(promptText).toContain("Edit: `apply_patch`"); + expect(promptText).toContain("`read`, `search`, `find`, `apply_patch`, `lsp`"); + expect(promptText).toContain("Use `apply_patch` for surgical text changes"); + expect(promptText).not.toContain("Edit: `edit`"); }); test("buildSystemPrompt omits CPU info when os.cpus fails", async () => { @@ -329,7 +337,7 @@ describe("system Handlebars prompt templates", () => { throw new Error("os.cpus() failed"); }); - const systemPrompt = await buildSystemPrompt({ + const { systemPrompt } = await buildSystemPrompt({ cwd: os.tmpdir(), contextFiles: [], skills: [], @@ -337,7 +345,9 @@ describe("system Handlebars prompt templates", () => { toolNames: ["read"], }); - const workstation = /\n(?[\s\S]*?)\n<\/workstation>/u.exec(systemPrompt)?.groups?.content; + const projectPrompt = systemPrompt[1] ?? ""; + + const workstation = /\n(?[\s\S]*?)\n<\/workstation>/u.exec(projectPrompt)?.groups?.content; expect(workstation).toContain("OS:"); expect(workstation).not.toContain("CPU:"); }); diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index 3e3194849..d1097ef60 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -49,7 +49,7 @@ function createMockSession( const session = { state, - agent: { state: { systemPrompt: "test" } }, + agent: { state: { systemPrompt: ["test"] } }, model: undefined, extensionRunner: undefined, sessionManager: { diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index fb28e5f7b..3d10ecd96 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -38,7 +38,6 @@ function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): fs.utimesSync(filePath, mtime, mtime); } - function createFifoOrSkip(fifoPath: string): boolean { if (process.platform === "win32") { return false; diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index c237f3fef..49801828e 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -54,7 +54,7 @@ export interface TestSessionOptions { /** Use in-memory session (no file persistence) */ inMemory?: boolean; /** Custom system prompt */ - systemPrompt?: string; + systemPrompt?: string | string[]; /** Custom settings overrides */ settingsOverrides?: Record; } @@ -91,7 +91,9 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { model, - systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.", + systemPrompt: Array.isArray(options.systemPrompt) + ? options.systemPrompt + : [options.systemPrompt ?? "You are a helpful assistant. Be extremely concise."], tools, }, }); diff --git a/packages/typescript-edit-benchmark/src/in-process-client.ts b/packages/typescript-edit-benchmark/src/in-process-client.ts index 96ec0592d..d69cc7d61 100644 --- a/packages/typescript-edit-benchmark/src/in-process-client.ts +++ b/packages/typescript-edit-benchmark/src/in-process-client.ts @@ -94,7 +94,7 @@ export class InProcessClient { modelRegistry: shared?.modelRegistry, sessionManager: SessionManager.inMemory(this.#options.cwd), systemPrompt: this.#options.appendSystemPrompt - ? (defaultPrompt: string) => `${defaultPrompt}\n\n${this.#options.appendSystemPrompt}` + ? (defaultPrompt: string[]) => [...defaultPrompt, this.#options.appendSystemPrompt!] : undefined, toolNames: this.#options.tools ?? ["read", "edit", "write"], hasUI: false, @@ -162,7 +162,7 @@ export class InProcessClient { async getState(): Promise<{ sessionFile?: string; - systemPrompt?: string; + systemPrompt?: string[]; model?: Model; thinkingLevel?: ThinkingLevel | undefined; dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/typescript-edit-benchmark/src/runner.ts index f11ce13e2..7cd0d0b76 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/typescript-edit-benchmark/src/runner.ts @@ -33,7 +33,7 @@ function formatLogPath(logFile: string): string { /** Subset of session state used for markdown conversation dumps (parity with /dump). */ type ConversationDumpSessionState = { sessionFile?: string; - systemPrompt?: string; + systemPrompt?: string[]; model?: Model; thinkingLevel?: ThinkingLevel | undefined; dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; @@ -99,7 +99,7 @@ export interface BenchmarkConfig { type ConversationDumpSnapshot = { messages: AgentMessage[]; sourceSessionFile?: string; - systemPrompt?: string; + systemPrompt?: string[]; model?: Model; thinkingLevel?: ThinkingLevel | undefined; dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; @@ -1050,7 +1050,7 @@ async function runSingleTask( } const initialState = await client.getState(); - const systemPromptTokens = estimateTokens(initialState.systemPrompt ?? ""); + const systemPromptTokens = estimateTokens(initialState.systemPrompt?.join("\n\n") ?? ""); const maxAttempts = Math.max(1, Math.floor(config.maxAttempts ?? 1)); const maxTimeoutRetries = config.maxTimeoutRetries ?? 3;