From 8dab70281bc03ad70407afc87da4818353d94707 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 09:52:26 +0200 Subject: [PATCH 01/22] Revert "feat(coding-agent): add cache-friendly compaction primitives" This reverts commit cff1cf52c6c73ef873dcb6148238ed68f69e1ead. --- packages/ai/src/index.ts | 1 - packages/coding-agent/docs/compaction.md | 2 - .../coding-agent/src/core/agent-session.ts | 2 +- .../src/core/compaction/compaction.ts | 255 +++--------------- packages/coding-agent/src/index.ts | 1 - .../test/compaction-summary-reasoning.test.ts | 227 +--------------- packages/coding-agent/test/compaction.test.ts | 85 +----- 7 files changed, 45 insertions(+), 528 deletions(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 64142c999..82bac9738 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -37,7 +37,6 @@ export * from "./providers/faux.ts"; export * from "./session-resources.ts"; export * from "./types.ts"; export * from "./utils/diagnostics.ts"; -export { estimateContextTokens } from "./utils/estimate.ts"; export * from "./utils/event-stream.ts"; export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 1ed15fcd6..3a9dff1c6 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -281,9 +281,7 @@ pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event; // preparation.messagesToSummarize - messages to summarize - // preparation.sourceMessages - optional active-context history prefix, including any previous summary // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn) - // preparation.turnPrefixSourceMessages - optional active-context prefix through the split turn // preparation.previousSummary - previous compaction summary // preparation.fileOps - extracted file operations // preparation.tokensBefore - context tokens before compaction diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4d02d1943..74280d564 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1813,7 +1813,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), - undefined, // cacheFriendly + undefined, // sessionId ); } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 7200da96f..a532e774d 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,14 +6,7 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { - contentText, - estimateContextTokens as estimateProviderContextTokens, - type RetryCallbacks, - type RetryPolicy, - retryAssistantCall, - uuidv7, -} from "@earendil-works/pi-ai"; +import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; @@ -102,27 +95,6 @@ function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | u return sessionEntryToContextMessages(entry)[0]; } -/** Build an active-context prefix, placing the previous compaction summary before its retained messages. */ -function collectSourceMessages( - entries: SessionEntry[], - startIndex: number, - endIndex: number, - previousCompactionIndex: number, -): AgentMessage[] { - const messages: AgentMessage[] = []; - if (previousCompactionIndex >= 0) { - messages.push(...sessionEntryToContextMessages(entries[previousCompactionIndex])); - } - for (let i = startIndex; i < endIndex; i++) { - // The latest compaction was moved to the front above. Keep older compaction - // entries because buildSessionContext retains them in the active provider prefix. - if (i !== previousCompactionIndex) { - messages.push(...sessionEntryToContextMessages(entries[i])); - } - } - return messages; -} - /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { summary: string; @@ -168,19 +140,6 @@ export interface CompactionSettings { keepRecentTokens: number; } -/** Active provider contexts and request settings used to preserve cacheable compaction prefixes. */ -export interface CacheFriendlySummaryOptions { - /** Exact provider context prefix containing the history to summarize. */ - sourceContext?: Context; - /** Exact provider context prefix containing a split turn's prefix. */ - turnPrefixSourceContext?: Context; - /** Provider request settings copied from the active agent request path. */ - requestOptions?: Pick< - SimpleStreamOptions, - "sessionId" | "onPayload" | "onResponse" | "transport" | "thinkingBudgets" | "maxRetryDelayMs" - >; -} - export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { enabled: true, reserveTokens: 16384, @@ -590,10 +549,6 @@ const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation mes ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; -const SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT = `The messages above contain an existing structured summary of earlier conversation history followed by NEW conversation messages. - -${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; - function createSummarizationOptions( model: Model, maxTokens: number, @@ -602,18 +557,9 @@ function createSummarizationOptions( env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, - requestOptions: CacheFriendlySummaryOptions["requestOptions"] | undefined, - cacheRetention: SimpleStreamOptions["cacheRetention"] | undefined, + sessionId: string | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { - ...requestOptions, - maxTokens, - signal, - apiKey, - headers, - env, - cacheRetention, - }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; const refusalFallbacks = getAnthropicSummarizationFallback(model); if (refusalFallbacks) { options.refusalFallbacks = refusalFallbacks; @@ -639,15 +585,12 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Standalone one-off summaries default to no prompt caching. Cache-friendly callers - // explicitly request short retention so providers can reuse the active prefix. - // Callers without a session ID, including standalone branch summaries, receive a fresh routing ID. + // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; + // callers without a session ID, including branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, - cacheRetention: options.cacheRetention ?? "none", + cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(), - // Anthropic invalidates the messages cache when tool_choice changes. Its 20-content-block lookup - // can still reuse an earlier user-message checkpoint, but long tool-heavy turns may need reprocessing. toolChoice: "none", }; const produce = async (): Promise => @@ -675,7 +618,7 @@ export async function generateSummary( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: Pick, + sessionId?: string, ): Promise { return ( await generateSummaryWithUsage( @@ -692,47 +635,25 @@ export async function generateSummary( env, retry, callbacks, - cacheFriendly, + sessionId, ) ).text; } -/** Build a standalone summary request or append its instruction to an existing provider context. */ -function buildSummarizationContext(promptText: string, sourceContext?: Context): Context { - const instructionMessage = { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }; - - if (sourceContext) { - return { - ...sourceContext, - messages: [...sourceContext.messages, instructionMessage], - }; - } - +/** Build the provider context for a standalone summary request. */ +function buildSummarizationContext(promptText: string): Context { return { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, - messages: [instructionMessage], + messages: [ + { + role: "user", + content: [{ type: "text", text: promptText }], + timestamp: Date.now(), + }, + ], }; } -/** - * Extra room for provider framing and tokenizer variance omitted by the heuristic context estimate. - * This matches the 4096-token margin used when normal simple requests clamp maxTokens to their context window. - */ -const CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS = 4096; - -/** Whether the source context leaves room for the requested summary output and provider safety margin. */ -function cacheFriendlyContextFits(model: Model, context: Context, maxTokens: number): boolean { - return ( - model.contextWindow <= 0 || - estimateProviderContextTokens(context).tokens + maxTokens + CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS <= - model.contextWindow - ); -} - /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], @@ -748,55 +669,31 @@ export async function generateSummaryWithUsage( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: Pick, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); - // Provider-visible history prefix to reuse instead of serializing messages into a standalone prompt. - let sourceContext = cacheFriendly?.sourceContext; - // Cache-friendly source contexts already contain the previous compaction summary, - // but still need iterative-update instructions so prior information is preserved. - let basePrompt = previousSummary - ? sourceContext - ? SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT - : UPDATE_SUMMARIZATION_PROMPT - : SUMMARIZATION_PROMPT; + // Use update prompt if we have a previous summary, otherwise initial prompt + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } - // A valid prefix can still be too large to leave the intended summary budget, - // especially during overflow recovery or after switching to a smaller-context model. - // In that case, use standalone serialization, which truncates large tool results. - if ( - sourceContext && - !cacheFriendlyContextFits(model, buildSummarizationContext(basePrompt, sourceContext), maxTokens) - ) { - sourceContext = undefined; - basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; - if (customInstructions) { - basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; - } - } + // Serialize conversation to text so model doesn't try to continue it + // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); - // Source contexts already contain the conversation. Standalone requests serialize it - // so the model treats those messages as data rather than continuing the conversation. - let promptText = ""; - if (!sourceContext) { - const llmMessages = convertToLlm(currentMessages); - const conversationText = serializeConversation(llmMessages); - promptText = `\n${conversationText}\n\n\n`; - } - if (previousSummary && !sourceContext) { + // Build the prompt with conversation wrapped in tags + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { promptText += `\n${previousSummary}\n\n\n`; } promptText += basePrompt; - const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined; - const sourceCacheRetention = sourceContext ? "short" : undefined; const completionOptions = createSummarizationOptions( model, maxTokens, @@ -805,13 +702,12 @@ export async function generateSummaryWithUsage( env, signal, thinkingLevel, - sourceRequestOptions, - sourceCacheRetention, + sessionId, ); const response = await completeSummarization( model, - buildSummarizationContext(promptText, sourceContext), + buildSummarizationContext(promptText), completionOptions, streamFn, retry, @@ -839,15 +735,8 @@ export interface CompactionPreparation { firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; - /** - * Active-context prefix for the history summary. - * Includes the previous compaction summary before messages retained by that compaction. - */ - sourceMessages?: AgentMessage[]; /** Messages that will be turned into turn prefix summary (if splitting) */ turnPrefixMessages: AgentMessage[]; - /** Active-context prefix through the split-turn prefix, or empty when not splitting. */ - turnPrefixSourceMessages?: AgentMessage[]; /** Whether this is a split turn (cut point in middle of turn) */ isSplitTurn: boolean; tokensBefore: number; @@ -905,8 +794,6 @@ export function prepareCompaction( if (msg) messagesToSummarize.push(msg); } - const sourceMessages = collectSourceMessages(pathEntries, boundaryStart, historyEnd, prevCompactionIndex); - // Messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { @@ -915,9 +802,6 @@ export function prepareCompaction( if (msg) turnPrefixMessages.push(msg); } } - const turnPrefixSourceMessages = cutPoint.isSplitTurn - ? collectSourceMessages(pathEntries, boundaryStart, cutPoint.firstKeptEntryIndex, prevCompactionIndex) - : []; if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { return undefined; @@ -936,9 +820,7 @@ export function prepareCompaction( return { firstKeptEntryId, messagesToSummarize, - sourceMessages, turnPrefixMessages, - turnPrefixSourceMessages, isSplitTurn: cutPoint.isSplitTurn, tokensBefore, previousSummary, @@ -966,30 +848,13 @@ Summarize the prefix to provide context for the retained suffix: Be concise. Focus on what's needed to understand the kept suffix.`; -const SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT = `The final turn in the source conversation was too large to keep in full. Its SUFFIX (recent work) is retained. - -The source conversation may also contain complete earlier turns for background. Summarize only the final, incomplete turn. It begins with the last user-role request before this instruction. Do not summarize earlier turns except for details needed to understand this final turn's prefix. - -Summarize the prefix to provide context for the retained suffix: - -## Original Request -[What did the user ask for in this turn?] - -## Early Progress -- [Key decisions and work done in the prefix] - -## Context for Suffix -- [Information needed to understand the retained recent work] - -Be concise. Focus on what's needed to understand the kept suffix.`; - /** * Generate summaries for compaction using prepared data. * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary - * @param cacheFriendly - Active provider contexts and request settings for cache-friendly summarization + * @param sessionId - Optional routing session ID forwarded without enabling prompt caching */ export async function compact( preparation: CompactionPreparation, @@ -1003,7 +868,7 @@ export async function compact( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: CacheFriendlySummaryOptions, + sessionId?: string, ): Promise { const { firstKeptEntryId, @@ -1038,10 +903,7 @@ export async function compact( env, retry, callbacks, - { - sourceContext: cacheFriendly?.sourceContext, - requestOptions: cacheFriendly?.requestOptions, - }, + sessionId, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -1058,10 +920,7 @@ export async function compact( streamFn, retry, callbacks, - { - sourceContext: cacheFriendly?.turnPrefixSourceContext, - requestOptions: cacheFriendly?.requestOptions, - }, + sessionId, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -1082,10 +941,7 @@ export async function compact( env, retry, callbacks, - { - sourceContext: cacheFriendly?.sourceContext, - requestOptions: cacheFriendly?.requestOptions, - }, + sessionId, ); summary = result.text; summaryUsage = result.usage; @@ -1123,51 +979,20 @@ async function generateTurnPrefixSummary( streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: Pick, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); // Smaller budget for turn prefix + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; - // Reuse the provider-visible split-turn prefix only when it leaves room for the summary; - // otherwise serialize and truncate the messages in a standalone prompt. - let sourceContext = cacheFriendly?.sourceContext; - if ( - sourceContext && - !cacheFriendlyContextFits( - model, - buildSummarizationContext(SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT, sourceContext), - maxTokens, - ) - ) { - sourceContext = undefined; - } - let promptText: string; - if (sourceContext) { - promptText = SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT; - } else { - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); - promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; - } - - const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined; - const sourceCacheRetention = sourceContext ? "short" : undefined; const response = await completeSummarization( model, - buildSummarizationContext(promptText, sourceContext), - createSummarizationOptions( - model, - maxTokens, - apiKey, - headers, - env, - signal, - thinkingLevel, - sourceRequestOptions, - sourceCacheRetention, - ), + buildSummarizationContext(promptText), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, callbacks, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7c9c596a4..6d64d70c8 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -28,7 +28,6 @@ export { readStoredCredential } from "./core/auth-storage.ts"; export { type BranchPreparation, type BranchSummaryResult, - type CacheFriendlySummaryOptions, type CollectEntriesResult, type CompactionResult, type CutPointResult, diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 462d48c7e..e45a69bfa 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -115,7 +115,7 @@ describe("generateSummary reasoning options", () => { expect(sessionIds[0]).not.toBe(sessionIds[1]); }); - it("honors caller-supplied cache retention and routing", async () => { + it("honors a caller-supplied routing session without prompt caching", async () => { await completeSummarization( createModel(false), { systemPrompt: "Summarize", messages: [] }, @@ -124,7 +124,7 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ sessionId: "current-routing-session", - cacheRetention: "long", + cacheRetention: "none", toolChoice: "none", }); }); @@ -148,229 +148,6 @@ describe("generateSummary reasoning options", () => { expect(prompt).toContain(""); }); - it("appends instructions to a cache-friendly source context", async () => { - const sourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [{ role: "user", content: "Previous summary and original request", timestamp: 1 }], - tools: [], - }; - const onPayload = async (payload: unknown) => payload; - const onResponse = async () => {}; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: messages, - turnPrefixMessages: [], - isSplitTurn: false, - tokensBefore: 100, - previousSummary: "Previous summary", - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - sourceContext, - requestOptions: { - sessionId: "routing-session", - onPayload, - onResponse, - transport: "websocket", - thinkingBudgets: { low: 1234 }, - maxRetryDelayMs: 4321, - }, - }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - expect(requestContext.systemPrompt).toBe(sourceContext.systemPrompt); - expect(requestContext.tools).toBe(sourceContext.tools); - expect(requestContext.messages.slice(0, -1)).toEqual(sourceContext.messages); - const instruction = JSON.stringify(requestContext.messages.at(-1)); - expect(instruction).toContain("existing structured summary of earlier conversation history"); - expect(instruction).toContain("PRESERVE all existing information from the previous summary"); - expect(instruction).not.toContain(""); - expect(instruction).not.toContain(""); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - cacheRetention: "short", - sessionId: "routing-session", - toolChoice: "none", - transport: "websocket", - thinkingBudgets: { low: 1234 }, - maxRetryDelayMs: 4321, - }); - expect(completeSimpleMock.mock.calls[0][2]?.onPayload).toBe(onPayload); - expect(completeSimpleMock.mock.calls[0][2]?.onResponse).toBe(onResponse); - expect(sourceContext.messages).toHaveLength(1); - }); - - it("falls back to standalone summarization when a cache-friendly source cannot leave the summary budget", async () => { - const oversizedToolResult: AgentMessage = { - role: "toolResult", - toolCallId: "tool-call-1", - toolName: "read", - content: [{ type: "text", text: "x".repeat(800_000) }], - isError: false, - timestamp: 1, - }; - const sourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [oversizedToolResult], - tools: [], - }; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: [oversizedToolResult], - turnPrefixMessages: [], - isSplitTurn: false, - tokensBefore: 250_000, - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { sourceContext, requestOptions: { sessionId: "routing-session" } }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - const prompt = JSON.stringify(requestContext.messages); - expect(requestContext.systemPrompt).not.toBe(sourceContext.systemPrompt); - expect(requestContext.tools).toBeUndefined(); - expect(prompt).toContain(""); - expect(prompt).toContain("more characters truncated"); - expect(prompt).not.toContain("x".repeat(3000)); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" }); - expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session"); - }); - - it("falls back to standalone summarization for an oversized split-turn source", async () => { - const splitUser = { role: "user" as const, content: "Large final request", timestamp: 1 }; - const oversizedToolResult: AgentMessage = { - role: "toolResult", - toolCallId: "tool-call-1", - toolName: "read", - content: [{ type: "text", text: "x".repeat(800_000) }], - isError: false, - timestamp: 2, - }; - const turnPrefixSourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [splitUser, oversizedToolResult], - tools: [], - }; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: [], - turnPrefixMessages: [splitUser, oversizedToolResult], - isSplitTurn: true, - tokensBefore: 250_000, - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { turnPrefixSourceContext, requestOptions: { sessionId: "routing-session" } }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - const prompt = JSON.stringify(requestContext.messages); - expect(requestContext.systemPrompt).not.toBe(turnPrefixSourceContext.systemPrompt); - expect(requestContext.tools).toBeUndefined(); - expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep"); - expect(prompt).toContain("more characters truncated"); - expect(prompt).not.toContain("source conversation may also contain complete earlier turns"); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" }); - expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session"); - }); - - it("limits a cache-friendly split-turn summary to the final incomplete turn", async () => { - const earlierUser = { role: "user" as const, content: "Earlier request", timestamp: 1 }; - const earlierAssistant: AssistantMessage = { - ...mockSummaryResponse, - content: [{ type: "text", text: "Earlier work completed" }], - timestamp: 2, - }; - const splitUser = { role: "user" as const, content: "Large final request", timestamp: 3 }; - const earlyAssistant: AssistantMessage = { - ...mockSummaryResponse, - content: [{ type: "text", text: "Early work in final turn" }], - timestamp: 4, - }; - const turnPrefixSourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [earlierUser, earlierAssistant, splitUser, earlyAssistant], - tools: [], - }; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: [], - turnPrefixMessages: [splitUser, earlyAssistant], - isSplitTurn: true, - tokensBefore: 100, - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { turnPrefixSourceContext }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - expect(requestContext.messages.slice(0, -1)).toEqual(turnPrefixSourceContext.messages); - const instruction = JSON.stringify(requestContext.messages.at(-1)); - expect(instruction).toContain("Summarize only the final, incomplete turn"); - expect(instruction).toContain("last user-role request before this instruction"); - expect(instruction).toContain("Do not summarize earlier turns"); - expect(instruction).not.toContain("Earlier request"); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - cacheRetention: "short", - toolChoice: "none", - }); - }); - it("rejects tool calls from conversation summaries", async () => { completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 8fe754ce6..12c9d0be2 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -5,7 +5,6 @@ import { readFileSync } from "fs"; import { join } from "path"; import { beforeEach, describe, expect, it } from "vitest"; import { - type CompactionPreparation, type CompactionSettings, calculateContextTokens, compact, @@ -151,17 +150,6 @@ function createCustomMessageEntry(content: string): CustomMessageEntry { return entry; } -function requireSourceMessages( - preparation: CompactionPreparation | undefined, -): asserts preparation is CompactionPreparation & { - sourceMessages: AgentMessage[]; - turnPrefixSourceMessages: AgentMessage[]; -} { - if (!preparation?.sourceMessages || !preparation.turnPrefixSourceMessages) { - throw new Error("Expected compaction source messages"); - } -} - function extractText(messages: AgentMessage[]): string { return messages .map((message) => { @@ -473,44 +461,6 @@ describe("buildSessionContext", () => { }); }); -describe("prepareCompaction source messages", () => { - it("provides the active history prefix for a normal compaction", () => { - const u1 = createMessageEntry(createUserMessage("old user ".repeat(20))); - const a1 = createMessageEntry(createAssistantMessage("old assistant ".repeat(20))); - const u2 = createMessageEntry(createUserMessage("recent user")); - const a2 = createMessageEntry(createAssistantMessage("ok")); - const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 3 }; - - const preparation = prepareCompaction([u1, a1, u2, a2], settings); - - expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - expect(preparation.isSplitTurn).toBe(false); - expect(preparation.sourceMessages).toEqual(preparation.messagesToSummarize); - expect(extractText(preparation.sourceMessages)).toContain("old user"); - expect(extractText(preparation.sourceMessages)).not.toContain("recent user"); - expect(preparation.turnPrefixSourceMessages).toEqual([]); - }); - - it("provides the active prefix through a split turn", () => { - const user = createMessageEntry(createUserMessage("large request ".repeat(20))); - const earlyAssistant = createMessageEntry(createAssistantMessage("early work ".repeat(20))); - const keptAssistant = createMessageEntry(createAssistantMessage("kept")); - const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 }; - - const preparation = prepareCompaction([user, earlyAssistant, keptAssistant], settings); - - expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - expect(preparation.isSplitTurn).toBe(true); - expect(preparation.messagesToSummarize).toEqual([]); - expect(preparation.sourceMessages).toEqual([]); - expect(preparation.turnPrefixSourceMessages).toEqual(preparation.turnPrefixMessages); - expect(extractText(preparation.turnPrefixSourceMessages)).toContain("large request"); - expect(extractText(preparation.turnPrefixSourceMessages)).not.toContain("kept"); - }); -}); - describe("prepareCompaction with previous compaction", () => { it("should skip repeated compactions when kept messages still fit", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)")); @@ -529,33 +479,6 @@ describe("prepareCompaction with previous compaction", () => { expect(preparation).toBeUndefined(); }); - it("preserves retained older summaries in the active source prefix", () => { - const u1 = createMessageEntry(createUserMessage("user 1")); - const a1 = createMessageEntry(createAssistantMessage("assistant 1")); - const u2 = createMessageEntry(createUserMessage("user 2 ".repeat(20))); - const a2 = createMessageEntry(createAssistantMessage("assistant 2 ".repeat(20))); - const compaction1 = createCompactionEntry("First summary", u1.id); - const u3 = createMessageEntry(createUserMessage("user 3 ".repeat(20))); - const a3 = createMessageEntry(createAssistantMessage("assistant 3 ".repeat(20))); - const compaction2 = createCompactionEntry("Second summary", u2.id); - const u4 = createMessageEntry(createUserMessage("user 4 ".repeat(20))); - const a4 = createMessageEntry(createAssistantMessage("kept")); - const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 }; - const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3, compaction2, u4, a4]; - - const preparation = prepareCompaction(pathEntries, settings); - - expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - const activeMessages = buildSessionContext(pathEntries).messages; - expect(preparation.sourceMessages).toEqual(activeMessages.slice(0, preparation.sourceMessages.length)); - expect(preparation.turnPrefixSourceMessages).toEqual( - activeMessages.slice(0, preparation.turnPrefixSourceMessages.length), - ); - const sourceSummaries = preparation.sourceMessages.filter((message) => message.role === "compactionSummary"); - expect(sourceSummaries.map((message) => message.summary)).toEqual(["Second summary", "First summary"]); - }); - it("should re-summarize previously kept messages when the recent window moves past them", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4))); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4))); @@ -574,15 +497,11 @@ describe("prepareCompaction with previous compaction", () => { const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings); expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - const summarizedText = extractText(preparation.messagesToSummarize); + const summarizedText = extractText(preparation!.messagesToSummarize); expect(summarizedText).toContain("user msg 2 - kept by compaction1"); expect(summarizedText).toContain("user msg 3 - kept by compaction1"); expect(summarizedText).not.toContain("First summary"); - expect(preparation.previousSummary).toBe("First summary"); - expect(preparation.sourceMessages[0]?.role).toBe("compactionSummary"); - expect(extractText(preparation.sourceMessages)).toContain("First summary"); - expect(preparation.sourceMessages.slice(1)).toEqual(preparation.messagesToSummarize); + expect(preparation!.previousSummary).toBe("First summary"); }); }); From 3a0b9a3eeee280f750edb3b0fc8a1b9093e88137 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 09:53:15 +0200 Subject: [PATCH 02/22] Revert "feat(agent): expose provider context construction" This reverts commit 2509b5c037d366979f2febfce4174b88aeaadc6a. --- packages/agent/src/agent-loop.ts | 39 ++++++++------------- packages/agent/src/agent.ts | 16 +-------- packages/agent/test/agent.test.ts | 57 +------------------------------ 3 files changed, 17 insertions(+), 95 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 1f9d44599..a251fede0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -274,29 +274,6 @@ async function runLoop( await emit({ type: "agent_end", messages: newMessages }); } -/** Build the provider context using the same transform and conversion pipeline as an agent request. */ -export async function buildProviderContext( - context: AgentContext, - config: Pick, - signal?: AbortSignal, -): Promise { - // Apply context transform if configured (AgentMessage[] → AgentMessage[]) - let messages = context.messages; - if (config.transformContext) { - messages = await config.transformContext(messages, signal); - } - - // Convert to LLM-compatible messages (AgentMessage[] → Message[]) - const llmMessages = await config.convertToLlm(messages); - - // Build LLM context - return { - systemPrompt: context.systemPrompt, - messages: llmMessages, - tools: context.tools, - }; -} - /** * Stream an assistant response from the LLM. * This is where AgentMessage[] gets transformed to Message[] for the LLM. @@ -308,7 +285,21 @@ async function streamAssistantResponse( emit: AgentEventSink, streamFunction: StreamFn, ): Promise { - const llmContext = await buildProviderContext(context, config, signal); + // Apply context transform if configured (AgentMessage[] → AgentMessage[]) + let messages = context.messages; + if (config.transformContext) { + messages = await config.transformContext(messages, signal); + } + + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) + const llmMessages = await config.convertToLlm(messages); + + // Build LLM context + const llmContext: Context = { + systemPrompt: context.systemPrompt, + messages: llmMessages, + tools: context.tools, + }; // Resolve API key (important for expiring tokens) const resolvedApiKey = diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index dfd4f9391..0de7edd83 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,5 +1,4 @@ import type { - Context, ImageContent, Message, Model, @@ -8,11 +7,7 @@ import type { ThinkingBudgets, Transport, } from "@earendil-works/pi-ai"; -import { - buildProviderContext as buildProviderContextFromAgentContext, - runAgentLoop, - runAgentLoopContinue, -} from "./agent-loop.ts"; +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import { getDefaultStreamFn } from "./stream-fn.ts"; import type { AfterToolCallContext, @@ -266,15 +261,6 @@ export class Agent { return this._state; } - /** Build a provider context through the same transform and conversion pipeline used by agent requests. */ - async buildProviderContext(context: AgentContext, signal?: AbortSignal): Promise { - return buildProviderContextFromAgentContext( - context, - { convertToLlm: this.convertToLlm, transformContext: this.transformContext }, - signal, - ); - } - /** Controls how queued steering messages are drained. */ set steeringMode(mode: QueueMode) { this.steeringQueue.mode = mode; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 7642374b3..672c4a678 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,16 +1,9 @@ -import { - type AssistantMessage, - type AssistantMessageEvent, - EventStream, - getModel, - type Message, -} from "@earendil-works/pi-ai/compat"; +import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { Agent, type AgentEvent, - type AgentMessage, type AgentTool, type AgentToolUpdateCallback, type StreamFn, @@ -141,54 +134,6 @@ describe("Agent", () => { expect(agent.state.thinkingLevel).toBe("low"); }); - it("builds provider context through configured transforms", async () => { - const sourceMessages: AgentMessage[] = [ - { role: "user", content: "discard", timestamp: 1 }, - { role: "user", content: "keep", timestamp: 2 }, - ]; - const transformedMessages: AgentMessage[] = [sourceMessages[1]]; - const callOrder: string[] = []; - const abortController = new AbortController(); - let transformInput: AgentMessage[] | undefined; - let convertInput: AgentMessage[] | undefined; - const agent = new Agent({ - streamFn: unusedStreamFunction, - transformContext: async (messages, signal) => { - callOrder.push("transform"); - transformInput = messages; - expect(signal).toBe(abortController.signal); - return transformedMessages; - }, - convertToLlm: async (messages) => { - callOrder.push("convert"); - convertInput = messages; - return messages.filter( - (message): message is Message => - message.role === "user" || message.role === "assistant" || message.role === "toolResult", - ); - }, - }); - const tools: AgentTool[] = []; - - const context = await agent.buildProviderContext( - { - systemPrompt: "System prompt", - messages: sourceMessages, - tools, - }, - abortController.signal, - ); - - expect(callOrder).toEqual(["transform", "convert"]); - expect(transformInput).toBe(sourceMessages); - expect(convertInput).toBe(transformedMessages); - expect(context).toEqual({ - systemPrompt: "System prompt", - messages: transformedMessages, - tools, - }); - }); - it("should subscribe to events", () => { const agent = new Agent({ streamFn: unusedStreamFunction }); From ed867e90947910c907d7d4b9d1b7a8586448f648 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:30:26 +0200 Subject: [PATCH 03/22] fix(ai): fallback cost not via stream options (#8352) * fix(ai): fallback cost not via stream options * fix: always pass beta header, remove fallback from stream options --- packages/ai/scripts/generate-models.ts | 24 ++++++-------- packages/ai/src/api/anthropic-messages.ts | 33 +++++++------------ packages/ai/src/types.ts | 19 +++++------ .../src/core/compaction/compaction.ts | 15 --------- .../test/compaction-summary-reasoning.test.ts | 14 ++++---- 5 files changed, 37 insertions(+), 68 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d376149dc..bf732af93 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -752,18 +752,20 @@ function isAnthropicFallbackMetadataModel(model: Model): model is Model<"an ); } -function applyAnthropicFallbackCostMetadata(models: readonly Model<"anthropic-messages">[]): void { +function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anthropic-messages">[]): void { const modelsById = new Map(models.map((model) => [model.id, model])); for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) { const model = modelsById.get(modelId); - if (!model?.compat?.allowedFallbackModels) continue; + if (!model) continue; - for (const fallbackModelId of fallbackModelIds) { - const fallback = model.compat.allowedFallbackModels.find((target) => target.model === fallbackModelId); + const allowedFallbackModels = fallbackModelIds.flatMap((fallbackModelId) => { const fallbackModel = modelsById.get(fallbackModelId); - if (fallback && fallbackModel) { - fallback.cost = fallbackModel.cost; - } + return fallbackModel + ? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }] + : []; + }); + if (allowedFallbackModels.length > 0) { + mergeAnthropicMessagesCompat(model, { allowedFallbackModels }); } } } @@ -985,12 +987,6 @@ function getAnthropicMessagesCompat(provider: string, modelId: string): Anthropi if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) { compat.supportsEagerToolInputStreaming = false; } - if (provider === "anthropic") { - const allowedFallbackModels = ANTHROPIC_ALLOWED_FALLBACK_MODELS[modelId]; - if (allowedFallbackModels) { - compat.allowedFallbackModels = allowedFallbackModels.map((fallbackModel) => ({ model: fallbackModel })); - } - } if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { compat.allowEmptySignature = true; } @@ -2809,7 +2805,7 @@ async function generateModels() { applyOpenAIToolSearchMetadata(model); applyOpenAIExplicitPromptCacheMetadata(model); } - applyAnthropicFallbackCostMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); + applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); // Group by provider and deduplicate by model ID const providers: Record>> = {}; diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index b224e52d2..f36b54755 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -10,7 +10,6 @@ import type { import { calculateCost } from "../models.ts"; import type { AnthropicMessagesCompat, - AnthropicRefusalFallback, Api, AssistantMessage, CacheRetention, @@ -170,13 +169,17 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type AnthropicThinkingDisplay = "summarized" | "omitted"; type MessageCreateParamsStreamingWithFallbacks = MessageCreateParamsStreaming & { - fallbacks?: AnthropicRefusalFallback; + fallbacks?: readonly { model: string }[]; }; const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01"; +function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): boolean { + return (model.compat?.allowedFallbackModels?.length ?? 0) > 0; +} + function getAnthropicCompat( model: Model<"anthropic-messages">, ): Required> { @@ -254,12 +257,6 @@ export interface AnthropicOptions extends StreamOptions { * Default: true. */ interleavedThinking?: boolean; - /** - * Anthropic refusal fallback. When set, the request includes the server-side - * fallback beta and Anthropic retries eligible refusals on the configured - * fallback target before returning a final response. - */ - refusalFallbacks?: AnthropicRefusalFallback; /** * Anthropic tool choice behavior. String values map to Anthropic's built-in * choices; `{ type: "tool", name }` forces a specific tool. @@ -566,7 +563,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, context), - options?.refusalFallbacks !== undefined, + shouldUseServerSideFallbackBeta(model), options?.headers, options?.fetch, copilotDynamicHeaders, @@ -606,10 +603,9 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( const fallbackCost = output.model === model.id ? undefined - : ((Array.isArray(options?.refusalFallbacks) - ? options.refusalFallbacks.find((fallback) => fallback.model === output.model)?.cost - : undefined) ?? - model.compat?.allowedFallbackModels?.find((fallback) => fallback.model === output.model)?.cost); + : model.compat?.allowedFallbackModels?.find( + (fallback) => fallback.provider === model.provider && fallback.model === output.model, + )?.cost; usageModel = fallbackCost ? { ...model, id: output.model, cost: fallbackCost } : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early @@ -850,7 +846,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti if (!options?.reasoning) { return stream(model, context, { ...base, - refusalFallbacks: options?.refusalFallbacks, thinkingEnabled: false, } satisfies AnthropicOptions); } @@ -861,7 +856,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti const effort = mapThinkingLevelToEffort(model, options.reasoning); return stream(model, context, { ...base, - refusalFallbacks: options?.refusalFallbacks, thinkingEnabled: true, effort, } satisfies AnthropicOptions); @@ -880,7 +874,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti return stream(model, context, { ...base, - refusalFallbacks: options?.refusalFallbacks, maxTokens, thinkingEnabled: true, thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)), @@ -1124,11 +1117,9 @@ function buildParams( } } - if (options?.refusalFallbacks !== undefined) { - params.fallbacks = - options.refusalFallbacks === "default" - ? "default" - : options.refusalFallbacks.map((fallback) => ({ model: fallback.model })); + const allowedFallbackModels = model.compat?.allowedFallbackModels; + if (allowedFallbackModels && allowedFallbackModels.length > 0) { + params.fallbacks = allowedFallbackModels.map((fallback) => ({ model: fallback.model })); } return params; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 271873099..b24103819 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -304,21 +304,17 @@ export interface ImagesOptions extends ProviderRequestOptions; -export interface AnthropicRefusalFallbackTarget { +export interface AnthropicAllowedFallbackModel { + provider: ProviderId; model: string; - /** Local pricing for this fallback target. Stripped before sending the provider request. @internal */ - cost?: ModelCost; + cost: ModelCost; } -export type AnthropicRefusalFallback = "default" | readonly AnthropicRefusalFallbackTarget[]; - // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { /** Provider-neutral tool selection for simple requests. Default: "auto". */ toolChoice?: ToolChoice; reasoning?: ThinkingLevel; - /** Anthropic server-side fallback for eligible refusal stop reasons. Anthropic providers only. */ - refusalFallbacks?: AnthropicRefusalFallback; /** Ask a capable provider to return a durable handle and continue the request asynchronously. */ deferred?: boolean | { window?: "15m" | "1h" | "24h" }; /** Custom token budgets for thinking levels (token-based providers only) */ @@ -697,11 +693,12 @@ export interface AnthropicMessagesCompat { /** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */ supportsStrictTools?: boolean; /** - * Model ids Anthropic accepts in `fallbacks` for server-side refusal fallback. - * When absent or empty, callers must omit `fallbacks`; Anthropic rejects the - * field for models with no permitted fallback targets. + * Models Anthropic accepts in `fallbacks` for server-side refusal fallback, + * with local pricing metadata for returned fallback responses. When absent or + * empty, callers must omit `fallbacks`; Anthropic rejects the field for models + * with no permitted fallback targets. */ - allowedFallbackModels?: AnthropicRefusalFallbackTarget[]; + allowedFallbackModels?: AnthropicAllowedFallbackModel[]; /** * Whether the provider supports deferred tools loaded by `tool_reference` * blocks in tool results. Default: true for first-party Anthropic models diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index a532e774d..b7df9da34 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -26,17 +26,6 @@ import { serializeConversation, } from "./utils.ts"; -function getAnthropicSummarizationFallback(model: Model): SimpleStreamOptions["refusalFallbacks"] { - if (model.provider !== "anthropic" || model.api !== "anthropic-messages") { - return undefined; - } - - const allowedFallbackModels = (model as Model<"anthropic-messages">).compat?.allowedFallbackModels; - // Use the primary permitted fallback for now. If future Anthropic models expose - // broader fallback behavior, this can become a user/config pick or a full chain. - return allowedFallbackModels && allowedFallbackModels.length > 0 ? [allowedFallbackModels[0]] : undefined; -} - // ============================================================================ // File Operation Tracking // ============================================================================ @@ -560,10 +549,6 @@ function createSummarizationOptions( sessionId: string | undefined, ): SimpleStreamOptions { const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; - const refusalFallbacks = getAnthropicSummarizationFallback(model); - if (refusalFallbacks) { - options.refusalFallbacks = refusalFallbacks; - } if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index e45a69bfa..037469cab 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -213,14 +213,16 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning"); }); - it("sets Anthropic refusal fallback from model metadata", async () => { - const fallbackCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }; + it("leaves Anthropic refusal fallback handling to pi-ai model metadata", async () => { await generateSummary( messages, createModel(true, 8192, { allowedFallbackModels: [ - { model: "claude-opus-4-8", cost: fallbackCost }, - { model: "claude-opus-5", cost: fallbackCost }, + { + provider: "anthropic", + model: "claude-opus-4-8", + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, ], }), 2000, @@ -228,9 +230,7 @@ describe("generateSummary reasoning options", () => { ); expect(completeSimpleMock).toHaveBeenCalledTimes(1); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - refusalFallbacks: [{ model: "claude-opus-4-8", cost: fallbackCost }], - }); + expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks"); }); it("does not set Anthropic refusal fallback for models without allowed fallback targets", async () => { From d711bd5f0a5fca7f10f8a681763b5ed5e63d9400 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 12:02:37 +0200 Subject: [PATCH 04/22] fix(coding-agent): preserve branch summary source leaf Record the pre-navigation leaf in branch summary fromId instead of the destination node. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/session-manager.ts | 3 ++- packages/coding-agent/test/branch-summary-extensions.test.ts | 4 +++- .../coding-agent/test/session-manager/tree-traversal.test.ts | 5 +++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e857000f5..84eecd820 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,7 @@ - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). +- Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf. ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index cd9d2f437..f8db9b0e5 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1388,13 +1388,14 @@ export class SessionManager { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } + const fromId = this.leafId ?? "root"; this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), - fromId: branchFromId ?? "root", + fromId, summary, details, usage, diff --git a/packages/coding-agent/test/branch-summary-extensions.test.ts b/packages/coding-agent/test/branch-summary-extensions.test.ts index 9c47cb81a..ecdc01797 100644 --- a/packages/coding-agent/test/branch-summary-extensions.test.ts +++ b/packages/coding-agent/test/branch-summary-extensions.test.ts @@ -38,12 +38,14 @@ describe("Branch summary extensions", () => { const targetId = harness.sessionManager.appendMessage(userMsg("first branch")); harness.sessionManager.appendMessage(assistantMsg("first reply")); harness.sessionManager.appendMessage(userMsg("abandoned branch work")); - harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); + const sourceId = harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); const result = await harness.session.navigateTree(targetId, { summarize: true }); const summaryEntry = result.summaryEntry; expect(summaryEntry?.type).toBe("branch_summary"); + expect(summaryEntry?.parentId).toBeNull(); + expect(summaryEntry?.fromId).toBe(sourceId); expect(summaryEntry?.fromHook).toBe(true); expect(summaryEntry?.summary).toBe("Summary provided by extension"); expect(summaryEntry?.usage).toEqual(usage); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index d6e20f043..123dc65cd 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -321,12 +321,12 @@ describe("SessionManager append and tree traversal", () => { }); describe("branchWithSummary", () => { - it("inserts branch summary and advances leaf", () => { + it("inserts branch summary with the source and destination and advances leaf", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); const _id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); + const id3 = session.appendMessage(userMsg("3")); const usage = { input: 10, @@ -345,6 +345,7 @@ describe("SessionManager append and tree traversal", () => { expect(summaryEntry).toBeDefined(); expect(summaryEntry?.parentId).toBe(id1); if (summaryEntry?.type === "branch_summary") { + expect(summaryEntry.fromId).toBe(id3); expect(summaryEntry.summary).toBe("Summary of abandoned work"); expect(summaryEntry.usage).toEqual(usage); } From 830a0a59e975ed3a4e551be18dc60d45479f5118 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Wed, 19 Aug 2026 12:24:30 +0200 Subject: [PATCH 05/22] fix(coding-agent): expose tool metadata at stream start (#7953) * fix(coding-agent): expose tool metadata at stream start * fix(coding-agent): centralize message update serialization --- packages/coding-agent/docs/json.md | 9 ++- packages/coding-agent/docs/rpc.md | 12 +++- packages/coding-agent/src/modes/json-event.ts | 61 ++++++++++++------- .../7925-toolcall-start-metadata.test.ts | 43 +++++++++++++ 4 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index ddb9cb305..2d538802e 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -15,12 +15,16 @@ except that streaming message updates omit cumulative snapshots: ```typescript type WithoutPartial = T extends { partial: unknown } ? Omit : T; +type JsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; + type JsonAgentSessionEvent = | Exclude | { type: "message_update"; usage: Usage; - assistantMessageEvent: WithoutPartial; + assistantMessageEvent: JsonAssistantMessageEvent; }; ``` @@ -84,7 +88,8 @@ Followed by events as they occur: `assistantMessageEvent.partial` to keep stream size linear. The top-level `usage` field contains the latest cumulative provider-reported usage and may remain zero when a provider only reports usage at completion. Use `contentIndex` and `delta` to assemble live text, thinking, or tool-call -arguments if needed. `message_end` contains the final authoritative message. +arguments if needed. A `toolcall_start` event also includes the constant-sized `id` and `toolName` +fields. `message_end` contains the final authoritative message. ## Example diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index fc4c69867..f92c12e8e 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -945,7 +945,7 @@ The `assistantMessageEvent` field contains one of these delta types: | `thinking_start` | Thinking block started | | `thinking_delta` | Thinking content chunk | | `thinking_end` | Thinking block ended | -| `toolcall_start` | Tool call started | +| `toolcall_start` | Tool call started (includes `id` and `toolName`) | | `toolcall_delta` | Tool call arguments chunk | | `toolcall_end` | Tool call ended (includes full `toolCall` object) | @@ -960,11 +960,17 @@ Example streaming a text response: The top-level `usage` field contains the latest cumulative provider-reported usage. It may remain zero until completion when a provider does not report usage during streaming. +Example starting a tool call: +```json +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":1,"id":"call_abc123","toolName":"write"}} +``` + `message_update` intentionally omits the former cumulative `message` field and `assistantMessageEvent.partial`. Clients that need a live partial message must assemble it from `message_start` and subsequent events using `contentIndex`. Treat `message_end.message` -as authoritative. For tool calls, buffer `toolcall_delta.delta`; `toolcall_end.toolCall` -contains the completed call. +as authoritative. For tool calls, `toolcall_start` provides the call `id` and `toolName`; +buffer `toolcall_delta.delta` for arguments. `toolcall_end.toolCall` contains the completed +call. ### bash_execution_update diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index f300b17a9..c0c04fde0 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -3,28 +3,45 @@ import type { AgentSessionEvent } from "../core/agent-session.ts"; type WithoutPartial = T extends { partial: unknown } ? Omit : T; -type ToJsonEvent = T extends { - type: "message_update"; - assistantMessageEvent: infer TAssistantMessageEvent; -} - ? { - type: "message_update"; - usage: Usage; - assistantMessageEvent: WithoutPartial; - } - : T; - -/** Session event shape emitted by the JSON and RPC stdout protocols. */ -export type JsonAgentSessionEvent = ToJsonEvent; +type ToJsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; type MessageUpdateEvent = Extract; -type JsonMessageUpdateEvent = Extract; +type JsonMessageUpdateEvent = { + type: "message_update"; + usage: Usage; + assistantMessageEvent: ToJsonAssistantMessageEvent; +}; + +/** Session event shape emitted by the JSON and RPC stdout protocols. */ +export type JsonAgentSessionEvent = Exclude | JsonMessageUpdateEvent; + +function toJsonAssistantMessageEvent( + event: MessageUpdateEvent["assistantMessageEvent"], +): JsonMessageUpdateEvent["assistantMessageEvent"] { + if (event.type === "toolcall_start") { + const toolCall = event.partial.content[event.contentIndex]; + if (toolCall?.type !== "toolCall") { + throw new Error(`toolcall_start content at index ${event.contentIndex} is not a tool call`); + } + const { partial: _partial, ...deltaEvent } = event; + return { ...deltaEvent, id: toolCall.id, toolName: toolCall.name }; + } + + if (!("partial" in event)) { + return event; + } + + const { partial: _partial, ...deltaEvent } = event; + return deltaEvent; +} /** * Remove cumulative assistant snapshots from streaming wire events. * `message_start` provides the initial message, deltas build it, and - * `message_end` provides the final authoritative message. Cumulative usage - * remains available because its size is constant. + * `message_end` provides the final authoritative message. Cumulative usage, + * tool-call ids, and tool names remain available because their size is constant. */ export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent; export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent; @@ -36,11 +53,9 @@ export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent { throw new Error("message_update message is not an assistant message"); } - const assistantMessageEvent = event.assistantMessageEvent; - if (!("partial" in assistantMessageEvent)) { - return { type: "message_update", usage: event.message.usage, assistantMessageEvent }; - } - - const { partial: _partial, ...deltaEvent } = assistantMessageEvent; - return { type: "message_update", usage: event.message.usage, assistantMessageEvent: deltaEvent }; + return { + type: "message_update", + usage: event.message.usage, + assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent), + }; } diff --git a/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts new file mode 100644 index 000000000..e58de9e97 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts @@ -0,0 +1,43 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { toJsonEvent } from "../../../src/modes/json-event.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("regression #7925: tool-call metadata is available when streaming starts", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes the tool call id and name without cumulative snapshots", async () => { + harness = await createHarness(); + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall("write", { path: "output.txt", content: "x".repeat(100) }, { id: "call_7925" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("write a file"); + + const update = harness + .eventsOfType("message_update") + .find((event) => event.assistantMessageEvent.type === "toolcall_start"); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected toolcall_start assistant update"); + } + + expect(toJsonEvent(update)).toEqual({ + type: "message_update", + usage: update.message.usage, + assistantMessageEvent: { + type: "toolcall_start", + contentIndex: 0, + id: "call_7925", + toolName: "write", + }, + }); + }); +}); From d57e531f5dc57974e2f9ce7a618730e5358a45db Mon Sep 17 00:00:00 2001 From: Seiji Toyama Date: Wed, 19 Aug 2026 19:42:27 +0900 Subject: [PATCH 06/22] fix(ai): round-trip Bedrock redacted reasoning (#8314) Bedrock Converse returns encrypted reasoning from non-Anthropic models (e.g. OpenAI GPT-5.6) as the opaque `reasoningContent.redactedContent` member. The stream handler only read `text` and `signature`, so the payload was dropped and replayed history lost the reasoning block. Buffer the delta bytes, encode them into `thinkingSignature` with `redacted: true` (the representation the Anthropic path already uses), and lower them back to `reasoningContent.redactedContent` on the next turn. Scratch state is stripped from every terminal path, since a stream can settle without stopping each block. --- .../ai/src/api/bedrock-converse-stream.ts | 106 ++++++- .../test/bedrock-redacted-reasoning.test.ts | 274 ++++++++++++++++++ 2 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 packages/ai/test/bedrock-redacted-reasoning.test.ts diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 57aab387f..2e944fcd2 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -101,10 +101,18 @@ export interface BedrockOptions extends StreamOptions { bearerToken?: string; } -type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; +type Block = (TextContent | ThinkingContent | ToolCall) & { + index?: number; + partialJson?: string; + /** Scratch buffer for encrypted reasoning deltas, joined into `thinkingSignature`. */ + redactedChunks?: Uint8Array[]; +}; const EMPTY_TEXT_PLACEHOLDER = ""; +/** Matches the placeholder the Anthropic API path uses for redacted thinking. */ +const REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]"; + export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, @@ -313,13 +321,13 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = throw new Error(output.errorMessage || "An unknown error occurred"); } + // A stream can settle without stopping every block, so finalize here too. + for (const block of output.content) finalizeStreamingBlock(block as Block); stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { for (const block of output.content) { - delete (block as Block).index; - // partialJson is only a streaming scratch buffer; never persist it. - delete (block as Block).partialJson; + finalizeStreamingBlock(block as Block); } output.stopReason = options.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatBedrockError(error); @@ -624,14 +632,56 @@ function handleContentBlockDelta( partial: output, }); } - if (delta.reasoningContent.signature) { + // `thinkingSignature` holds either an Anthropic signature or an opaque redacted + // payload, never both: mixing them would corrupt whichever arrived first. + if (delta.reasoningContent.signature && !thinkingBlock.redacted) { thinkingBlock.thinkingSignature = (thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature; } + if (delta.reasoningContent.redactedContent?.length) { + // Encrypted reasoning from non-Anthropic models on Bedrock (e.g. OpenAI GPT-5.6). + // The payload is opaque, so keep it verbatim in `thinkingSignature` the way the + // Anthropic path stores redacted thinking, and replay it on the next turn. + if (!thinkingBlock.redacted) { + thinkingBlock.redacted = true; + thinkingBlock.thinkingSignature = ""; + thinkingBlock.thinking += REDACTED_THINKING_PLACEHOLDER; + stream.push({ + type: "thinking_delta", + contentIndex: thinkingIndex, + delta: REDACTED_THINKING_PLACEHOLDER, + partial: output, + }); + } + thinkingBlock.redactedChunks ??= []; + thinkingBlock.redactedChunks.push(delta.reasoningContent.redactedContent); + } } } } +/** + * Encodes buffered encrypted reasoning into `thinkingSignature` and drops the scratch + * buffer, which must never reach a persisted message: `Uint8Array` serializes to an + * index-keyed object roughly ten times the size of the base64 payload. + */ +function flushRedactedContent(block: Block): void { + if (block.type !== "thinking" || !block.redactedChunks) return; + block.thinkingSignature = bytesToBase64(block.redactedChunks); + delete block.redactedChunks; +} + +/** + * Strips every streaming scratch field. Runs from the terminal paths as well as + * `contentBlockStop`, because a stream can settle without stopping each block. + */ +function finalizeStreamingBlock(block: Block): void { + delete block.index; + // partialJson is only a streaming scratch buffer; never persist it. + delete block.partialJson; + flushRedactedContent(block); +} + function handleMetadata( event: ConverseStreamMetadataEvent, model: Model<"bedrock-converse-stream">, @@ -663,6 +713,7 @@ function handleContentBlockStop( stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); break; case "thinking": + flushRedactedContent(block); stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output }); break; case "toolCall": @@ -936,6 +987,15 @@ function convertMessages( }); break; case "thinking": { + // Encrypted reasoning is opaque: replay the stored payload as the + // `redactedContent` member instead of lowering it to reasoning text. + if (c.redacted) { + const redactedContent = decodeRedactedContent(c.thinkingSignature); + if (redactedContent?.length) { + contentBlocks.push({ reasoningContent: { redactedContent } }); + } + continue; + } // Skip empty thinking blocks const thinking = sanitizeSurrogates(c.thinking); if (thinking.trim().length === 0) continue; @@ -1223,11 +1283,43 @@ function createImageBlock(mimeType: string, data: string) { throw new Error(`Unknown image type: ${mimeType}`); } + return { source: { bytes: base64ToBytes(data) }, format }; +} + +function base64ToBytes(data: string): Uint8Array { const binaryString = atob(data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } - - return { source: { bytes }, format }; + return bytes; +} + +/** + * Decodes a stored redacted payload. The AWS SDK hands the blob over as bytes, but a + * persisted session carries it as base64. A hand-edited or externally produced session + * can hold a signature that is not base64; drop that block instead of failing the + * whole request. + */ +function decodeRedactedContent(signature: string | undefined): Uint8Array | undefined { + if (!signature) return undefined; + try { + return base64ToBytes(signature); + } catch { + return undefined; + } +} + +function bytesToBase64(chunks: Uint8Array[]): string { + // Encrypted reasoning runs to tens of KB, so build the binary string in slices + // rather than one concatenation per byte. The window stays under the engine's + // argument-count limit for spread calls. + const WINDOW = 0x8000; + let binary = ""; + for (const chunk of chunks) { + for (let i = 0; i < chunk.length; i += WINDOW) { + binary += String.fromCharCode(...chunk.subarray(i, i + WINDOW)); + } + } + return btoa(binary); } diff --git a/packages/ai/test/bedrock-redacted-reasoning.test.ts b/packages/ai/test/bedrock-redacted-reasoning.test.ts new file mode 100644 index 000000000..86301e155 --- /dev/null +++ b/packages/ai/test/bedrock-redacted-reasoning.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * OpenAI models served through Bedrock Converse (e.g. `global.openai.gpt-5.6-terra`) + * return encrypted reasoning as the opaque `redactedContent` member of + * `reasoningContent`, not as `reasoningText`. The AWS SDK decodes the wire blob to + * `Uint8Array`. + * @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlockDelta.html + */ +const bedrockMock = vi.hoisted(() => { + const redactedBase64 = "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVOaWtSUWJF"; + return { + redactedBase64, + redactedBytes: new Uint8Array(Buffer.from(redactedBase64, "base64")), + streamEvents: undefined as unknown[] | undefined, + }; +}); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + send(): Promise { + if (bedrockMock.streamEvents) { + const events = bedrockMock.streamEvents; + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + stream: (async function* () { + yield* events; + })(), + }); + } + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import type { Context, Message, Model, ThinkingContent } from "../src/types.ts"; + +const gptModel: Model<"bedrock-converse-stream"> = { + id: "global.openai.gpt-5.6-terra", + name: "GPT-5.6 Terra (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.ap-northeast-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +const emptyUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +/** Mirrors the ConverseStream frames GPT-5.6 emits: encrypted reasoning, then text. */ +function redactedReasoningEvents(): unknown[] { + return [ + { messageStart: { role: "assistant" } }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { contentBlockDelta: { contentBlockIndex: 1, delta: { text: "done" } } }, + { contentBlockStop: { contentBlockIndex: 1 } }, + { messageStop: { stopReason: "end_turn" } }, + ]; +} + +interface BedrockRequestPayload { + messages: Array<{ role: string; content: Array> }>; +} + +async function capturePayload(context: Context): Promise { + let capturedPayload: BedrockRequestPayload | undefined; + const s = streamBedrock(gptModel, context, { + cacheRetention: "none", + signal: AbortSignal.abort(), + onPayload: (payload) => { + capturedPayload = payload as BedrockRequestPayload; + return payload; + }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + if (!capturedPayload) { + throw new Error("Expected Bedrock payload to be captured before request abort"); + } + return capturedPayload; +} + +describe("Bedrock redacted reasoning", () => { + beforeEach(() => { + bedrockMock.streamEvents = undefined; + }); + + it("does not fail the stream when reasoning arrives as redactedContent", async () => { + bedrockMock.streamEvents = redactedReasoningEvents(); + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + expect(response.stopReason, response.errorMessage).not.toBe("error"); + // Reasoning precedes the answer, matching the order Bedrock streamed it. + expect(response.content.map((c) => c.type)).toEqual(["thinking", "text"]); + expect(response.content[1]).toEqual({ type: "text", text: "done" }); + }); + + it("preserves the encrypted reasoning payload on the assistant message", async () => { + bedrockMock.streamEvents = redactedReasoningEvents(); + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking).toBeDefined(); + // Same representation Anthropic redacted thinking already uses: the opaque + // payload rides in `thinkingSignature` with `redacted: true`. + expect(thinking?.redacted).toBe(true); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // The byte buffer is streaming scratch state: persisting it would bloat the + // session, since a Uint8Array serializes to an index-keyed object. + expect("redactedChunks" in thinking!).toBe(false); + }); + + it("encodes the payload when the stream never sends contentBlockStop", async () => { + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + }, + }, + { messageStop: { stopReason: "end_turn" } }, + ]; + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // Streaming scratch state must not survive into the persisted message. + expect("redactedChunks" in thinking!).toBe(false); + expect("index" in thinking!).toBe(false); + }); + + it("joins encrypted reasoning split across deltas", async () => { + const [head, tail] = [bedrockMock.redactedBytes.slice(0, 7), bedrockMock.redactedBytes.slice(7)]; + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: head } } } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: tail } } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: "end_turn" } }, + ]; + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // The placeholder marks the block once, not once per delta. + expect(thinking?.thinking).toBe("[Reasoning redacted]"); + }); + + it("replays redacted reasoning as reasoningContent.redactedContent", async () => { + const messages: Message[] = [ + { role: "user", content: "hello", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true }, + { type: "text", text: "done" }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: gptModel.id, + usage: emptyUsage, + stopReason: "stop", + timestamp: Date.now(), + }, + { role: "user", content: "continue", timestamp: Date.now() }, + ]; + + const payload = await capturePayload({ messages }); + + const assistant = payload.messages.find((m: any) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant!.content).toEqual([ + { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + { text: "done" }, + ]); + }); + + it("replays redacted reasoning before the toolUse block it belongs to", async () => { + // Bedrock rejects a tool continuation whose reasoning block is missing or + // reordered, so the opaque payload must land ahead of the matching toolUse. + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true }, + { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "/tmp/a.txt" } }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: gptModel.id, + usage: emptyUsage, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "file body" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const payload = await capturePayload({ messages }); + + const assistant = payload.messages.find((m: any) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant!.content).toEqual([ + { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + { toolUse: { toolUseId: "tool-1", name: "read", input: { path: "/tmp/a.txt" } } }, + ]); + }); +}); From 1e1a6e27be36aa6e4e607137a648bb342a9a2477 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 12:45:44 +0200 Subject: [PATCH 07/22] feat(coding-agent): include paths in settings errors --- .../coding-agent/src/core/settings-manager.ts | 40 ++++++++++++++++--- .../test/settings-manager.test.ts | 8 +++- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 22e72b8df..8fb8cbaa1 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -190,9 +190,20 @@ export interface SettingsStorage { export interface SettingsError { scope: SettingsScope; + path?: string; error: Error; } +type SettingsPaths = Partial>; + +function toSettingsError(scope: SettingsScope, error: unknown, path?: string): SettingsError { + return { + scope, + ...(path ? { path } : {}), + error: error instanceof Error ? error : new Error(String(error)), + }; +} + export class FileSettingsStorage implements SettingsStorage { private globalSettingsPath: string; private projectSettingsPath: string; @@ -293,6 +304,7 @@ export class SettingsManager { private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors private writeQueue: Promise = Promise.resolve(); private errors: SettingsError[]; + private settingsPaths: SettingsPaths; private constructor( storage: SettingsStorage, @@ -302,6 +314,7 @@ export class SettingsManager { projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], projectTrusted = true, + settingsPaths: SettingsPaths = {}, ) { this.storage = storage; this.globalSettings = initialGlobal; @@ -310,6 +323,7 @@ export class SettingsManager { this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; + this.settingsPaths = settingsPaths; this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } @@ -319,21 +333,35 @@ export class SettingsManager { agentDir: string = getAgentDir(), options: SettingsManagerCreateOptions = {}, ): SettingsManager { - const storage = new FileSettingsStorage(cwd, agentDir); - return SettingsManager.fromStorage(storage, options); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const storage = new FileSettingsStorage(resolvedCwd, resolvedAgentDir); + return SettingsManager.fromStorageWithPaths(storage, options, { + global: join(resolvedAgentDir, "settings.json"), + project: join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"), + }); } /** Create a SettingsManager from an arbitrary storage backend */ static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + return SettingsManager.fromStorageWithPaths(storage, options); + } + + /** Create a manager while retaining optional file paths for reported storage errors. */ + private static fromStorageWithPaths( + storage: SettingsStorage, + options: SettingsManagerCreateOptions, + settingsPaths: SettingsPaths = {}, + ): SettingsManager { const projectTrusted = options.projectTrusted ?? true; const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { - initialErrors.push({ scope: "global", error: globalLoad.error }); + initialErrors.push(toSettingsError("global", globalLoad.error, settingsPaths.global)); } if (projectLoad.error) { - initialErrors.push({ scope: "project", error: projectLoad.error }); + initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project)); } return new SettingsManager( @@ -344,6 +372,7 @@ export class SettingsManager { projectLoad.error, initialErrors, projectTrusted, + settingsPaths, ); } @@ -546,8 +575,7 @@ export class SettingsManager { } private recordError(scope: SettingsScope, error: unknown): void { - const normalizedError = error instanceof Error ? error : new Error(String(error)); - this.errors.push({ scope, error: normalizedError }); + this.errors.push(toSettingsError(scope, error, this.settingsPaths[scope])); } private clearModifiedScope(scope: SettingsScope): void { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 638a70f23..3a2dbd0f7 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -185,7 +185,7 @@ describe("SettingsManager", () => { expect(manager.getDefaultModel()).toBe("claude-sonnet"); }); - it("should keep previous settings when file is invalid", async () => { + it("should keep previous settings and report the file path when the file is invalid", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); @@ -195,6 +195,7 @@ describe("SettingsManager", () => { await manager.reload(); expect(manager.getTheme()).toBe("dark"); + expect(manager.drainErrors()).toMatchObject([{ scope: "global", path: settingsPath }]); }); }); @@ -227,7 +228,10 @@ describe("SettingsManager", () => { const errors = manager.drainErrors(); expect(errors).toHaveLength(2); - expect(errors.map((e) => e.scope).sort()).toEqual(["global", "project"]); + expect(errors).toMatchObject([ + { scope: "global", path: globalSettingsPath }, + { scope: "project", path: projectSettingsPath }, + ]); expect(manager.drainErrors()).toEqual([]); }); }); From 913bcf3391be680a41fd6cb9acfe5d0fbfb3f7d9 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 12:55:36 +0200 Subject: [PATCH 08/22] fix(coding-agent): report settings diagnostic paths --- .../src/core/settings-diagnostics.ts | 25 ++++++++++ packages/coding-agent/src/main.ts | 15 ++---- .../test/settings-diagnostics.test.ts | 47 +++++++++++++++++++ 3 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/src/core/settings-diagnostics.ts create mode 100644 packages/coding-agent/test/settings-diagnostics.test.ts diff --git a/packages/coding-agent/src/core/settings-diagnostics.ts b/packages/coding-agent/src/core/settings-diagnostics.ts new file mode 100644 index 000000000..8dfec3899 --- /dev/null +++ b/packages/coding-agent/src/core/settings-diagnostics.ts @@ -0,0 +1,25 @@ +import type { AgentSessionRuntimeDiagnostic } from "./agent-session-services.ts"; +import type { SettingsManager } from "./settings-manager.ts"; + +export function collectSettingsDiagnostics(settingsManager: SettingsManager): AgentSessionRuntimeDiagnostic[] { + return settingsManager.drainErrors().map(({ scope, path, error }) => ({ + type: "warning", + message: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`, + })); +} + +/** + * Remove duplicate type/message diagnostics while preserving their first occurrence. + * Startup and runtime settings managers can report the same file error. + */ +export function deduplicateDiagnostics( + diagnostics: readonly AgentSessionRuntimeDiagnostic[], +): AgentSessionRuntimeDiagnostic[] { + const seen = new Set(); + return diagnostics.filter((diagnostic) => { + const key = `${diagnostic.type}\0${diagnostic.message}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 85d952359..f9f626a9e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -56,6 +56,7 @@ import { type SessionCwdIssue, } from "./core/session-cwd.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; +import { collectSettingsDiagnostics } from "./core/settings-diagnostics.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; @@ -92,16 +93,6 @@ async function readPipedStdin(): Promise { }); } -function collectSettingsDiagnostics( - settingsManager: SettingsManager, - context: string, -): AgentSessionRuntimeDiagnostic[] { - return settingsManager.drainErrors().map(({ scope, error }) => ({ - type: "warning", - message: `(${context}, ${scope} settings) ${error.message}`, - })); -} - function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void { for (const diagnostic of diagnostics) { const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim; @@ -656,7 +647,7 @@ export async function main(args: string[], options?: MainOptions) { time("runMigrations"); const startupSettingsManager = SettingsManager.create(cwd, agentDir); - reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager)); // Experimental first-time setup: theme choice and analytics opt-in. // Runs before any runtime services are created so the chosen settings apply everywhere. @@ -784,7 +775,7 @@ export async function main(args: string[], options?: MainOptions) { const diagnostics: AgentSessionRuntimeDiagnostic[] = [ ...projectTrustDiagnostics, ...services.diagnostics, - ...collectSettingsDiagnostics(settingsManager, "runtime creation"), + ...collectSettingsDiagnostics(settingsManager), ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ type: "error" as const, message: `Failed to load extension "${path}": ${error}`, diff --git a/packages/coding-agent/test/settings-diagnostics.test.ts b/packages/coding-agent/test/settings-diagnostics.test.ts new file mode 100644 index 000000000..9bda0fd6a --- /dev/null +++ b/packages/coding-agent/test/settings-diagnostics.test.ts @@ -0,0 +1,47 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { collectSettingsDiagnostics, deduplicateDiagnostics } from "../src/core/settings-diagnostics.ts"; +import { SettingsManager, type SettingsStorage } from "../src/core/settings-manager.ts"; + +describe("settings diagnostics", () => { + it("includes the settings file path for file-backed storage", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-settings-diagnostics-")); + const agentDir = join(tempDir, "agent"); + const settingsPath = join(agentDir, "settings.json"); + mkdirSync(agentDir); + writeFileSync(settingsPath, "{"); + + try { + const diagnostics = collectSettingsDiagnostics(SettingsManager.create(tempDir, agentDir)); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.type).toBe("warning"); + expect(diagnostics[0]?.message).toContain(`Invalid settings file ${settingsPath}:`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("falls back to the settings scope for storage without file paths", () => { + const storage: SettingsStorage = { + withLock(scope, fn) { + if (scope === "global") throw new Error("backend failed"); + fn(undefined); + }, + }; + const diagnostics = collectSettingsDiagnostics(SettingsManager.fromStorage(storage)); + + expect(diagnostics).toEqual([{ type: "warning", message: "Invalid global settings: backend failed" }]); + }); + + it("deduplicates diagnostics by type and message", () => { + const warning = { type: "warning" as const, message: "Invalid settings file /tmp/settings.json" }; + + expect(deduplicateDiagnostics([warning, warning, { ...warning, type: "error" }])).toEqual([ + warning, + { ...warning, type: "error" }, + ]); + }); +}); From 678f0af30d63650052fb3ed16e9b779af1a8f9e8 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 13:06:00 +0200 Subject: [PATCH 09/22] fix(coding-agent): show startup diagnostics in TUI fixes #7829 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/main.ts | 15 +++-- .../src/modes/interactive/interactive-mode.ts | 22 +++++++- .../7829-invalid-settings-warning.test.ts | 56 +++++++++++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84eecd820..0f9bcf02c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f9f626a9e..ac8b8cf71 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -56,7 +56,7 @@ import { type SessionCwdIssue, } from "./core/session-cwd.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; -import { collectSettingsDiagnostics } from "./core/settings-diagnostics.ts"; +import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/settings-diagnostics.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; @@ -647,7 +647,7 @@ export async function main(args: string[], options?: MainOptions) { time("runMigrations"); const startupSettingsManager = SettingsManager.create(cwd, agentDir); - reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager)); + const startupSettingsDiagnostics = collectSettingsDiagnostics(startupSettingsManager); // Experimental first-time setup: theme choice and analytics opt-in. // Runs before any runtime services are created so the chosen settings apply everywhere. @@ -847,6 +847,7 @@ export async function main(args: string[], options?: MainOptions) { configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { + reportDiagnostics(startupSettingsDiagnostics); const extensionFlags = resourceLoader .getExtensions() .extensions.flatMap((extension) => Array.from(extension.flags.values())); @@ -855,6 +856,7 @@ export async function main(args: string[], options?: MainOptions) { } if (parsed.listModels !== undefined) { + reportDiagnostics(startupSettingsDiagnostics); const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; await listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000)); process.exit(0); @@ -885,8 +887,12 @@ export async function main(args: string[], options?: MainOptions) { } time("resolveModelScope"); - reportDiagnostics(runtime.diagnostics); - if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) { + const startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]); + const hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === "error"); + if (appMode !== "interactive" || hasRuntimeErrors) { + reportDiagnostics(startupDiagnostics); + } + if (hasRuntimeErrors) { if (runtime.diagnostics.some((diagnostic) => diagnostic.message.includes("Failed to load extension"))) { console.error(chalk.yellow(EXTENSION_LOAD_FAILURE_HINT)); } @@ -921,6 +927,7 @@ export async function main(args: string[], options?: MainOptions) { } else if (appMode === "interactive") { const interactiveMode = new InteractiveMode(runtime, { migratedProviders, + startupDiagnostics, modelFallbackMessage, autoTrustOnReloadCwd, initialMessage, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 76c69b544..b008f0bb7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -58,6 +58,7 @@ import { } from "../../config.ts"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; +import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.ts"; import { CACHE_TTL_MS, type CacheMiss, @@ -332,6 +333,8 @@ function formatLoginProviderCompletionDescription(provider: LoginProviderComplet export interface InteractiveModeOptions { /** Providers that were migrated to auth.json (shows warning) */ migratedProviders?: string[]; + /** Diagnostics collected before the interactive TUI was initialized. */ + startupDiagnostics?: AgentSessionRuntimeDiagnostic[]; /** Warning message if session model couldn't be restored */ modelFallbackMessage?: string; /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ @@ -1090,7 +1093,24 @@ export class InteractiveMode { }); // Show startup warnings - const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; + const { + migratedProviders, + startupDiagnostics, + modelFallbackMessage, + initialMessage, + initialImages, + initialMessages, + } = this.options; + + for (const diagnostic of startupDiagnostics ?? []) { + if (diagnostic.type === "error") { + this.showError(diagnostic.message); + } else if (diagnostic.type === "warning") { + this.showWarning(diagnostic.message); + } else { + this.showStatus(diagnostic.message); + } + } if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); diff --git a/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts b/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts new file mode 100644 index 000000000..cb73e3602 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts @@ -0,0 +1,56 @@ +import { Container } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { AgentSessionRuntimeDiagnostic } from "../../../src/core/agent-session-services.ts"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { createHarness } from "../harness.ts"; + +function render(container: Container): string { + return container.children.flatMap((child) => child.render(120)).join("\n"); +} + +describe("issue #7829 invalid settings warning", () => { + beforeAll(() => initTheme("dark")); + + it("renders startup diagnostics inside the transcript", async () => { + const harness = await createHarness(); + const previousOffline = process.env.PI_OFFLINE; + process.env.PI_OFFLINE = "1"; + try { + const chatContainer = new Container(); + const startupDiagnostics: AgentSessionRuntimeDiagnostic[] = [ + { + type: "warning", + message: "Invalid settings file /tmp/settings.json: malformed JSON", + }, + ]; + const context = { + init: vi.fn(async () => {}), + options: { startupDiagnostics }, + chatContainer, + outputPad: 1, + ui: { requestRender: vi.fn() }, + version: "test", + showWarning: (InteractiveMode.prototype as unknown as { showWarning(message: string): void }).showWarning, + session: harness.session, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + getUserInput: vi.fn(() => new Promise(() => {})), + }; + const run = (InteractiveMode.prototype as unknown as { run(this: typeof context): Promise }).run; + + void run.call(context); + + await vi.waitFor(() => { + expect(render(chatContainer)).toContain( + "Warning: Invalid settings file /tmp/settings.json: malformed JSON", + ); + }); + } finally { + if (previousOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = previousOffline; + harness.cleanup(); + } + }); +}); From 3de00332f71a9a472d26cd9bca9dfc1961061e2b Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 13:19:26 +0200 Subject: [PATCH 10/22] fix(ai): derive Z.AI reasoning effort metadata Use models.dev effort options for Z.AI models while preserving GLM-5.2's none effort. Expose GLM-5.3's low, high, and max levels. closes #8336 --- packages/ai/scripts/generate-models.ts | 118 +++++++++--------- .../openai-completions-tool-choice.test.ts | 28 ++++- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index bf732af93..d91c99916 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -235,13 +235,6 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ "upstage/solar-10.7b-instruct", ]); const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); -const ZAI_GLM52_THINKING_LEVEL_MAP = { - minimal: null, - low: "high", - medium: "high", - high: "high", - max: "max", -} as const; const OPENCODE_GO_GLM52_THINKING_LEVEL_MAP = { off: null, minimal: null, @@ -1175,6 +1168,66 @@ async function fetchAiGatewayModels(): Promise[]> { } } +function processZaiModels(data: ModelsDevCatalog): Model[] { + const variants = [ + { + source: "zai-coding-plan", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + }, + { + source: "zhipuai-coding-plan", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + }, + ] as const; + const models: Model[] = []; + + for (const { source, provider, baseUrl } of variants) { + for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + const supportsImage = m.modalities?.input?.includes("image"); + + const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []); + const isGlm52 = modelId === "glm-5.2" || modelId === "glm-5.2-highspeed"; + if (thinkingLevelMap && isGlm52) { + thinkingLevelMap.off = "none"; + } + const supportsReasoningEffort = thinkingLevelMap !== undefined; + const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + reasoning: m.reasoning === true, + ...(thinkingLevelMap ? { thinkingLevelMap } : {}), + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: referenceCost?.input || 0, + output: referenceCost?.output || 0, + cacheRead: referenceCost?.cache_read || 0, + cacheWrite: referenceCost?.cache_write || 0, + }, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + ...(supportsReasoningEffort ? { supportsReasoningEffort: true } : {}), + ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + recordModelsDevReasoningOptions(provider, modelId, m); + } + } + + return models; +} + function processBasetenModels(provider: ModelsDevProvider | undefined): Model[] { if (!provider?.models) return []; @@ -1697,56 +1750,7 @@ async function loadModelsDevData(): Promise[]> { } } - // Process zAi models - const zaiCodingPlanVariants = [ - { - source: "zai-coding-plan", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - }, - { - source: "zhipuai-coding-plan", - provider: "zai-coding-cn", - baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", - }, - ] as const; - - for (const { source, provider, baseUrl } of zaiCodingPlanVariants) { - for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - const supportsImage = m.modalities?.input?.includes("image"); - - const isGlm52 = modelId === "glm-5.2"; - const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; - - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-completions", - provider, - baseUrl, - reasoning: m.reasoning === true, - ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), - input: supportsImage ? ["text", "image"] : ["text"], - cost: { - input: referenceCost?.input || 0, - output: referenceCost?.output || 0, - cacheRead: referenceCost?.cache_read || 0, - cacheWrite: referenceCost?.cache_write || 0, - }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - ...(isGlm52 ? { supportsReasoningEffort: true } : {}), - ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); - recordModelsDevReasoningOptions(provider, modelId, m); - } - } + models.push(...processZaiModels(data)); // Process Mistral models if (data.mistral?.models) { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 00ded1acf..c453f3d54 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -295,15 +295,31 @@ describe("openai-completions tool_choice", () => { expect(getModel("zai", "glm-5.2")?.compat?.zaiToolStream).toBe(true); }); - it("stores z.ai GLM-5.2 effort metadata", () => { + it("stores z.ai effort metadata", () => { for (const provider of ["zai", "zai-coding-cn"] as const) { - const model = getModel(provider, "glm-5.2")!; - expect(model.compat?.supportsReasoningEffort).toBe(true); - expect(model.thinkingLevelMap).toEqual({ + for (const modelId of ["glm-5.2", "glm-5.2-highspeed"] as const) { + const model = getModel(provider, modelId)!; + expect(model.compat?.supportsReasoningEffort).toBe(true); + expect(model.thinkingLevelMap).toEqual({ + off: "none", + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", + }); + } + + const glm53 = getModel(provider, "glm-5.3")!; + expect(glm53.compat?.supportsReasoningEffort).toBe(true); + expect(glm53.thinkingLevelMap).toEqual({ + off: null, minimal: null, - low: "high", - medium: "high", + low: "low", + medium: null, high: "high", + xhigh: null, max: "max", }); } From 4495469a5e8466eb67e3f26969922d3e7b208de2 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 13:44:00 +0200 Subject: [PATCH 11/22] fix(coding-agent): compact without provider usage closes #8328 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 25 +++--- .../8328-zero-usage-auto-compaction.test.ts | 80 +++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0f9bcf02c..99e42ebc3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). - Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf. +- Fixed threshold auto-compaction being skipped when providers omit streaming usage data ([#8328](https://github.com/earendil-works/pi/issues/8328)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 74280d564..9f11c5511 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2099,17 +2099,20 @@ export class AgentSession { if (assistantMessage.stopReason === "error" || directContextTokens === 0) { const messages = this.agent.state.messages; const estimate = estimateContextTokens(messages); - if (estimate.lastUsageIndex === null) return false; // No usage data at all - // Verify the usage source is post-compaction. Kept pre-compaction messages - // have stale usage reflecting the old (larger) context and would falsely - // trigger compaction right after one just finished. - const usageMsg = messages[estimate.lastUsageIndex]; - if ( - compactionEntry && - usageMsg.role === "assistant" && - (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() - ) { - return false; + // Without provider usage, estimate.tokens is the pure message-size estimate. + // Only usage-backed estimates need the stale pre-compaction check. + if (estimate.lastUsageIndex !== null) { + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() + ) { + return false; + } } contextTokens = estimate.tokens; } else { diff --git a/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts b/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts new file mode 100644 index 000000000..910690bee --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts @@ -0,0 +1,80 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +type SessionWithCompactionInternals = { + _checkCompaction: (assistantMessage: AssistantMessage) => Promise; + _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; +}; + +function createZeroUsageAssistant(harness: Harness): AssistantMessage { + const model = harness.getModel(); + return { + role: "assistant", + content: [{ type: "text", text: "response" }], + api: model.api, + provider: model.provider, + model: model.id, + 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: Date.now(), + }; +} + +describe("issue #8328 zero-usage auto-compaction", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + async function createCompactionHarness(): Promise { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 20 }], + settings: { compaction: { enabled: true, reserveTokens: 10 } }, + }); + harnesses.push(harness); + return harness; + } + + it("uses the message estimate when no assistant has reported usage", async () => { + const harness = await createCompactionHarness(); + const assistant = createZeroUsageAssistant(harness); + harness.session.agent.state.messages = [ + { role: "user", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() - 1 }, + assistant, + ]; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + + await sessionInternals._checkCompaction(assistant); + + expect(runAutoCompactionSpy).toHaveBeenCalledOnce(); + expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false); + }); + + it("does not compact when the zero-usage message estimate is below the threshold", async () => { + const harness = await createCompactionHarness(); + const assistant = createZeroUsageAssistant(harness); + harness.session.agent.state.messages = [ + { role: "user", content: [{ type: "text", text: "short" }], timestamp: Date.now() - 1 }, + assistant, + ]; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + + await sessionInternals._checkCompaction(assistant); + + expect(runAutoCompactionSpy).not.toHaveBeenCalled(); + }); +}); From 1355cd36e0b10a3e71c6c78f713b7b36458db27f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 19 Aug 2026 14:10:27 +0200 Subject: [PATCH 12/22] fix(coding-agent): normalize UTF-8 BOMs in text inputs closes #8337 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/cli/file-processor.ts | 3 +- packages/coding-agent/src/config.ts | 3 +- .../coding-agent/src/core/auth-storage.ts | 7 +-- packages/coding-agent/src/core/keybindings.ts | 3 +- .../coding-agent/src/core/model-config.ts | 3 +- .../coding-agent/src/core/models-store.ts | 3 +- .../coding-agent/src/core/package-manager.ts | 5 +- packages/coding-agent/src/core/pi-manifest.ts | 3 +- .../coding-agent/src/core/resource-loader.ts | 5 +- .../coding-agent/src/core/settings-manager.ts | 5 +- .../coding-agent/src/core/tools/edit-diff.ts | 8 +-- packages/coding-agent/src/core/tools/edit.ts | 4 +- .../coding-agent/src/core/trust-manager.ts | 3 +- packages/coding-agent/src/migrations.ts | 7 +-- .../src/modes/interactive/external-editor.ts | 3 +- .../src/modes/interactive/theme/theme.ts | 7 +-- .../coding-agent/src/utils/frontmatter.ts | 3 +- packages/coding-agent/src/utils/text.ts | 9 ++++ .../regressions/8337-utf8-bom-parsing.test.ts | 49 +++++++++++++++++++ 20 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 packages/coding-agent/src/utils/text.ts create mode 100644 packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 99e42ebc3..3744e2db8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 4b3bf0e20..af3c1c100 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -9,6 +9,7 @@ import { resolve } from "path"; import { resolveReadPath } from "../core/tools/path-utils.ts"; import { processImage } from "../utils/image-process.ts"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts"; +import { stripBom } from "../utils/text.ts"; export interface ProcessedFiles { text: string; @@ -73,7 +74,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process } else { // Handle text file try { - const content = await readFile(absolutePath, "utf-8"); + const content = stripBom(await readFile(absolutePath, "utf-8")); text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 38049f05c..3a252e5c8 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; import { spawnProcessSync } from "./utils/child-process.ts"; import { normalizePath } from "./utils/paths.ts"; +import { stripBom } from "./utils/text.ts"; // ============================================================================= // Package Detection @@ -478,7 +479,7 @@ interface PackageJson { let pkg: PackageJson = {}; try { - pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; + pkg = JSON.parse(stripBom(readFileSync(getPackageJsonPath(), "utf-8"))) as PackageJson; } catch (e: unknown) { const err = e as NodeJS.ErrnoException; if (err.code !== "ENOENT") throw e; diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 602e03a80..71be9d022 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -11,6 +11,7 @@ import { setTimeout as sleep } from "timers/promises"; import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts"; type AuthStorageData = Record; @@ -214,7 +215,7 @@ export class ReadOnlyAuthStorage implements CredentialStore { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(this.authPath, "utf-8")); + parsed = JSON.parse(stripBom(readFileSync(this.authPath, "utf-8"))); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.data = {}; @@ -364,7 +365,7 @@ export class AuthStorage implements CredentialStore { if (!content) { return {}; } - return JSON.parse(content) as AuthStorageData; + return JSON.parse(stripBom(content)) as AuthStorageData; } private updateReadState(data: AuthStorageData, revision?: string): void { @@ -499,7 +500,7 @@ export function readStoredCredential( authPath: string = join(getAgentDir(), "auth.json"), ): Credential | undefined { try { - const data = JSON.parse(readFileSync(normalizePath(authPath), "utf-8")) as AuthStorageData; + const data = JSON.parse(stripBom(readFileSync(normalizePath(authPath), "utf-8"))) as AuthStorageData; return data[providerId]; } catch { return undefined; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index ec5b135d2..0524b2a56 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -9,6 +9,7 @@ import { import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { getAgentDir } from "../config.ts"; +import { stripBom } from "../utils/text.ts"; export interface AppKeybindings { "app.interrupt": true; @@ -329,7 +330,7 @@ function orderKeybindingsConfig(config: Record): Record | undefined { if (!existsSync(path)) return undefined; try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + const parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))) as unknown; if (typeof parsed !== "object" || parsed === null) return undefined; return parsed as Record; } catch { diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 7ea12cbeb..f24a27937 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -6,6 +6,7 @@ import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; const PercentileCutoffsSchema = Type.Object({ p50: Type.Optional(Type.Number()), @@ -259,7 +260,7 @@ export class ModelConfig { let parsed: unknown; try { - parsed = JSON.parse(stripJsonComments(content)); + parsed = JSON.parse(stripJsonComments(stripBom(content))); } catch (error) { return new ModelConfig( new Map(), diff --git a/packages/coding-agent/src/core/models-store.ts b/packages/coding-agent/src/core/models-store.ts index 8251b59b2..bfac3c5e7 100644 --- a/packages/coding-agent/src/core/models-store.ts +++ b/packages/coding-agent/src/core/models-store.ts @@ -3,6 +3,7 @@ import type { ModelsStore, ModelsStoreEntry, ModelsStoreOperationOptions } from import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts"; type StoredModels = Record; @@ -59,7 +60,7 @@ export class FileModelsStore implements ModelsStore { } private parse(content: string | undefined): StoredModels { - return content ? (JSON.parse(content) as StoredModels) : {}; + return content ? (JSON.parse(stripBom(content)) as StoredModels) : {}; } private updateReadState(readState: ModelsFileReadState, data: StoredModels, revision?: string): void { diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index f0f98359c..805928c08 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -32,6 +32,7 @@ import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { isStdoutTakenOver } from "./output-guard.ts"; import { type PiManifest, readPiManifest } from "./pi-manifest.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts"; @@ -1479,7 +1480,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(packageJsonPath)) return undefined; try { const content = readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content) as { version?: string }; + const pkg = JSON.parse(stripBom(content)) as { version?: string }; return pkg.version; } catch { return undefined; @@ -1861,7 +1862,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(packageJsonPath)) return false; try { - const manifest = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { dependencies?: unknown }; + const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as { dependencies?: unknown }; if ( !manifest.dependencies || typeof manifest.dependencies !== "object" || diff --git a/packages/coding-agent/src/core/pi-manifest.ts b/packages/coding-agent/src/core/pi-manifest.ts index bff128cd5..fd7dd5edb 100644 --- a/packages/coding-agent/src/core/pi-manifest.ts +++ b/packages/coding-agent/src/core/pi-manifest.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { stripBom } from "../utils/text.ts"; export interface PiManifest { extensions?: string[]; @@ -15,7 +16,7 @@ function isObject(value: unknown): value is Record { export function readPiManifest(packageJsonPath: string): PiManifest | null { try { - const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const pkg: unknown = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))); if (!isObject(pkg) || !isObject(pkg.pi)) { return null; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index c24d0a210..04102425b 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -8,6 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { clearExtensionCache, @@ -57,7 +58,7 @@ function resolvePromptInput(input: string | undefined, description: string): str if (existsSync(input)) { try { - return readFileSync(input, "utf-8"); + return stripBom(readFileSync(input, "utf-8")); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; @@ -78,7 +79,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } } return { path: filePath, - content: readFileSync(filePath, "utf-8"), + content: stripBom(readFileSync(filePath, "utf-8")), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 8fb8cbaa1..fa2700640 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -7,6 +7,7 @@ import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { @@ -398,7 +399,7 @@ export class SettingsManager { if (!content) { return {}; } - const settings = JSON.parse(content); + const settings = JSON.parse(stripBom(content)); return SettingsManager.migrateSettings(settings); } @@ -619,7 +620,7 @@ export class SettingsManager { ): void { this.storage.withLock(scope, (current) => { const currentFileSettings = current - ? SettingsManager.migrateSettings(JSON.parse(current) as Record) + ? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record) : {}; const mergedSettings: Settings = { ...currentFileSettings }; for (const field of modifiedFields) { diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 5a4d966b0..c79f5eb7b 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -5,6 +5,7 @@ import * as Diff from "diff"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; +import { splitBom } from "../../utils/text.ts"; import { resolveToCwd } from "./path-utils.ts"; export function detectLineEnding(content: string): "\r\n" | "\n" { @@ -243,11 +244,6 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul }; } -/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ -export function stripBom(content: string): { bom: string; text: string } { - return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; -} - function countOccurrences(content: string, oldText: string): number { const fuzzyContent = normalizeForFuzzyMatch(content); const fuzzyOldText = normalizeForFuzzyMatch(oldText); @@ -535,7 +531,7 @@ export async function computeEditsDiff( const rawContent = await readFile(absolutePath, "utf-8"); // Strip BOM before matching (LLM won't include invisible BOM in oldText) - const { text: content } = stripBom(rawContent); + const { text: content } = splitBom(rawContent); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 149a2e157..eb124864c 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -5,6 +5,7 @@ import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.ts"; import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { splitBom } from "../../utils/text.ts"; import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import { @@ -18,7 +19,6 @@ import { generateUnifiedPatch, normalizeToLF, restoreLineEndings, - stripBom, } from "./edit-diff.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -361,7 +361,7 @@ export function createEditToolDefinition( throwIfAborted(); // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const { bom, text: content } = stripBom(rawContent); + const { bom, text: content } = splitBom(rawContent); const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 9c494b47a..0a560f7f9 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; export type ProjectTrustDecision = boolean | null; @@ -101,7 +102,7 @@ function readTrustFile(path: string): TrustFile { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(path, "utf-8")); + parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read trust store ${path}: ${message}`); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 39aeea043..e1aa79418 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, w import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts"; import { migrateKeybindingsConfig } from "./core/keybindings.ts"; +import { stripBom } from "./utils/text.ts"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; @@ -33,7 +34,7 @@ export function migrateAuthToAuthJson(): string[] { // Migrate oauth.json if (existsSync(oauthPath)) { try { - const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); + const oauth = JSON.parse(stripBom(readFileSync(oauthPath, "utf-8"))); for (const [provider, cred] of Object.entries(oauth)) { migrated[provider] = { type: "oauth", ...(cred as object) }; providers.push(provider); @@ -48,7 +49,7 @@ export function migrateAuthToAuthJson(): string[] { if (existsSync(settingsPath)) { try { const content = readFileSync(settingsPath, "utf-8"); - const settings = JSON.parse(content); + const settings = JSON.parse(stripBom(content)); if (settings.apiKeys && typeof settings.apiKeys === "object") { for (const [provider, key] of Object.entries(settings.apiKeys)) { if (!migrated[provider] && typeof key === "string") { @@ -159,7 +160,7 @@ function migrateKeybindingsConfigFile(): void { if (!existsSync(configPath)) return; try { - const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; + const parsed = JSON.parse(stripBom(readFileSync(configPath, "utf-8"))) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return; } diff --git a/packages/coding-agent/src/modes/interactive/external-editor.ts b/packages/coding-agent/src/modes/interactive/external-editor.ts index 45fb572f8..d672d4e08 100644 --- a/packages/coding-agent/src/modes/interactive/external-editor.ts +++ b/packages/coding-agent/src/modes/interactive/external-editor.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { stripBom } from "../../utils/text.ts"; export interface ExternalEditorOptions { command: string; @@ -34,7 +35,7 @@ export async function editInExternalEditor(options: ExternalEditorOptions): Prom return { status: "failed" }; } - return { status: "complete", content: readFileSync(filePath, "utf-8").replace(/\n$/, "") }; + return { status: "complete", content: stripBom(readFileSync(filePath, "utf-8")).replace(/\n$/, "") }; } finally { try { rmSync(directory, { recursive: true, force: true }); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 6fa0c8c92..c49bad29e 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -16,6 +16,7 @@ import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; import type { SourceInfo } from "../../../core/source-info.ts"; import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; +import { stripBom } from "../../../utils/text.ts"; // ============================================================================ // Types & Schema @@ -474,8 +475,8 @@ function getBuiltinThemes(): Record { const darkPath = path.join(themesDir, "dark.json"); const lightPath = path.join(themesDir, "light.json"); BUILTIN_THEMES = { - dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, - light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, + dark: JSON.parse(stripBom(fs.readFileSync(darkPath, "utf-8"))) as ThemeJson, + light: JSON.parse(stripBom(fs.readFileSync(lightPath, "utf-8"))) as ThemeJson, }; } return BUILTIN_THEMES; @@ -596,7 +597,7 @@ function parseThemeJson(label: string, json: unknown): ThemeJson { function parseThemeJsonContent(label: string, content: string): ThemeJson { let json: unknown; try { - json = JSON.parse(content); + json = JSON.parse(stripBom(content)); } catch (error) { throw new Error(`Failed to parse theme ${label}: ${error}`); } diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts index 847e2e539..544810737 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -1,4 +1,5 @@ import { parse } from "yaml"; +import { stripBom } from "./text.ts"; type ParsedFrontmatter> = { frontmatter: T; @@ -8,7 +9,7 @@ type ParsedFrontmatter> = { const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { - const normalized = normalizeNewlines(content); + const normalized = normalizeNewlines(stripBom(content)); if (!normalized.startsWith("---")) { return { yamlString: null, body: normalized }; diff --git a/packages/coding-agent/src/utils/text.ts b/packages/coding-agent/src/utils/text.ts new file mode 100644 index 000000000..737466e85 --- /dev/null +++ b/packages/coding-agent/src/utils/text.ts @@ -0,0 +1,9 @@ +/** Split a leading UTF-8 byte order mark from decoded text. */ +export function splitBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + +/** Remove a leading UTF-8 byte order mark from decoded text. */ +export function stripBom(content: string): string { + return splitBom(content).text; +} diff --git a/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts b/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts new file mode 100644 index 000000000..eef0d710e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts @@ -0,0 +1,49 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; +import { parseFrontmatter } from "../../../src/utils/frontmatter.ts"; +import { splitBom } from "../../../src/utils/text.ts"; + +describe("issue #8337 UTF-8 BOM parsing", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "pi-8337-")); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("loads frontmatter and settings with a leading BOM", async () => { + expect(splitBom("\uFEFFcontent")).toEqual({ bom: "\uFEFF", text: "content" }); + const document = "---\nname: demo\ndescription: Test\n---\nBody"; + expect(parseFrontmatter(`\uFEFF${document}`)).toEqual({ + frontmatter: { name: "demo", description: "Test" }, + body: "Body", + }); + + const agentDir = join(testDir, "agent"); + const projectDir = join(testDir, "project"); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + const globalSettingsPath = join(agentDir, "settings.json"); + writeFileSync(globalSettingsPath, `\uFEFF${JSON.stringify({ defaultModel: "global-model" })}`); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + `\uFEFF${JSON.stringify({ defaultProvider: "project-provider" })}`, + ); + + const settings = SettingsManager.create(projectDir, agentDir); + expect(settings.getDefaultModel()).toBe("global-model"); + expect(settings.getDefaultProvider()).toBe("project-provider"); + + settings.setTheme("dark"); + await settings.flush(); + expect(readFileSync(globalSettingsPath, "utf-8")).not.toMatch(/^\uFEFF/); + }); +}); From 87af49dec23f5aee132071ef33d4b2441f551e4b Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 15:56:26 +0200 Subject: [PATCH 13/22] Add pi user-agent to most api adapters (#8361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit added Pi’s default User-Agent to seven adapters: - openai-responses - openai-completions - anthropic-messages - azure-openai-responses - google-generative-ai - google-vertex - mistral-conversations Closes #8305 --- packages/ai/src/api/anthropic-messages.ts | 17 +-- packages/ai/src/api/azure-openai-responses.ts | 3 +- packages/ai/src/api/google-generative-ai.ts | 3 +- packages/ai/src/api/google-vertex.ts | 3 +- packages/ai/src/api/mistral-conversations.ts | 2 + packages/ai/src/api/openai-completions.ts | 8 +- packages/ai/src/api/openai-responses.ts | 8 +- packages/ai/src/utils/pi-user-agent.ts | 8 -- packages/ai/test/anthropic-auth-token.test.ts | 26 ++-- .../ai/test/azure-openai-base-url.test.ts | 24 ++++ .../ai/test/google-raw-stop-reason.test.ts | 32 +++++ .../google-vertex-api-key-resolution.test.ts | 21 +++- .../ai/test/mistral-http-transport.test.ts | 7 +- packages/ai/test/xai-responses.test.ts | 114 ++++++++++-------- 14 files changed, 175 insertions(+), 101 deletions(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index f36b54755..6c4ff62d0 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -281,18 +281,8 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid return merged; } -function mergeClientHeaders( - model: Model<"anthropic-messages">, - ...headerSources: (ProviderHeaders | undefined)[] -): ProviderHeaders { - const merged = mergeHeaders(...headerSources); - if (model.provider === "kimi-coding") { - for (const name of Object.keys(merged)) { - if (name.toLowerCase() === "user-agent") delete merged[name]; - } - merged["User-Agent"] = getPiUserAgent(); - } - return merged; +function mergeClientHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders { + return mergeHeaders({ "User-Agent": getPiUserAgent() }, ...headerSources); } function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { @@ -917,7 +907,6 @@ function createClient( dangerouslyAllowBrowser: true, fetch, defaultHeaders: mergeClientHeaders( - model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -941,7 +930,6 @@ function createClient( dangerouslyAllowBrowser: true, fetch, defaultHeaders: mergeClientHeaders( - model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -961,7 +949,6 @@ function createClient( const sessionAffinityHeaders: ProviderHeaders = sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; const defaultHeaders = mergeClientHeaders( - model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 3f106af87..56578805d 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -13,6 +13,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -253,7 +254,7 @@ function resolveAzureConfig( } function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { - const headers = { ...model.headers }; + const headers = { "User-Agent": getPiUserAgent(), ...model.headers }; if (options?.headers) { Object.assign(headers, options.headers); diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index fc534f2b7..a0f9f39c9 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -22,6 +22,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { @@ -344,7 +345,7 @@ function createClient( httpOptions.baseUrl = model.baseUrl; httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append } - const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders }); + const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders }); if (headers) { httpOptions.headers = headers; } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index e0b7edc65..cf8c13706 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -26,6 +26,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; @@ -391,7 +392,7 @@ function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: Provid } } - const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders }); + const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders }); if (headers) { httpOptions.headers = headers; } diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index d08349397..64bb10815 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -17,6 +17,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -329,6 +330,7 @@ class MistralHttpError extends Error { function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers { const headers = new Headers({ + "User-Agent": getPiUserAgent(), accept: "text/event-stream", authorization: `Bearer ${apiKey}`, "content-type": "application/json", diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 8d968521a..9f89b4b24 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -40,7 +40,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; -import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -647,7 +647,7 @@ function createClient( sessionId?: string, compat: ResolvedOpenAICompletionsCompat = getCompat(model), ) { - const headers: ProviderHeaders = { ...model.headers }; + const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ @@ -674,10 +674,6 @@ function createClient( Object.assign(headers, optionsHeaders); } - if (model.provider === "xai") { - forcePiUserAgent(headers); - } - return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 52ff366d5..6bfb3d65b 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -19,7 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; -import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -224,7 +224,7 @@ function createClient( sessionId?: string, ) { const compat = getCompat(model); - const headers: ProviderHeaders = { ...model.headers }; + const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ @@ -250,10 +250,6 @@ function createClient( Object.assign(headers, optionsHeaders); } - if (model.provider === "xai") { - forcePiUserAgent(headers); - } - return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts index c36d5ffbc..93b23dd35 100644 --- a/packages/ai/src/utils/pi-user-agent.ts +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -1,5 +1,4 @@ import type * as NodeOs from "node:os"; -import type { ProviderHeaders } from "../types.ts"; type ProcessWithOsBuiltinModule = typeof process & { getBuiltinModule?: (id: "node:os") => typeof NodeOs; @@ -18,10 +17,3 @@ const nodeOs = loadNodeOs(); export function getPiUserAgent(): string { return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; } - -export function forcePiUserAgent(headers: ProviderHeaders): void { - for (const name of Object.keys(headers)) { - if (name.toLowerCase() === "user-agent") delete headers[name]; - } - headers["User-Agent"] = getPiUserAgent(); -} diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index a80a021db..2bad50181 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -52,6 +52,7 @@ vi.mock("@anthropic-ai/sdk", () => { return { default: FakeAnthropic }; }); +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; const neverAbortedSignal = new AbortController().signal; const context: Context = { @@ -196,21 +197,20 @@ describe("Anthropic auth token env", () => { }); describe("Anthropic-compatible user agents", () => { - it("enforces the pi runtime user agent for Kimi Coding", async () => { - await streamAnthropic(kimiCodingModel, context, { - apiKey: "kimi-key", - headers: { "user-agent": "custom-client" }, - }).result(); - - const headers = mockState.constructorOpts?.defaultHeaders as Record; - const userAgentHeaders = Object.entries(headers).filter(([name]) => name.toLowerCase() === "user-agent"); - expect(userAgentHeaders).toEqual([["User-Agent", `pi (${platform()} ${release()}; ${arch()})`]]); - }); - - it("does not apply the pi runtime user agent to Anthropic", async () => { + it("uses pi's User-Agent by default for Anthropic Messages requests", async () => { await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result(); const headers = mockState.constructorOpts?.defaultHeaders as Record; - expect(Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")).toBe(false); + expect(headers["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default Anthropic Messages User-Agent", async () => { + await streamAnthropic(kimiCodingModel, context, { + apiKey: "kimi-key", + headers: { "User-Agent": "custom-client" }, + }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + expect(headers["User-Agent"]).toBe("custom-client"); }); }); diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 12e3fb25e..b317fd79f 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts"; @@ -40,6 +41,8 @@ vi.mock("openai", () => { return { AzureOpenAI }; }); +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; @@ -92,6 +95,17 @@ async function captureClientBaseUrl(baseUrl: string): Promise { return azureMock.constructorCalls[0].baseURL; } +async function captureClientHeaders(headers?: Record): Promise> { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + headers, + }).result(); + expect(azureMock.constructorCalls).toHaveLength(1); + return azureMock.constructorCalls[0].defaultHeaders ?? {}; +} + describe("azure-openai-responses base URL normalization", () => { it("normalizes Cognitive Services root endpoints to /openai/v1", async () => { const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com"); @@ -201,3 +215,13 @@ describe("azure-openai-responses base URL normalization", () => { expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1"); }); }); + +describe("azure-openai-responses user agent", () => { + it("uses pi's User-Agent by default", async () => { + expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default User-Agent", async () => { + expect((await captureClientHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent"); + }); +}); diff --git a/packages/ai/test/google-raw-stop-reason.test.ts b/packages/ai/test/google-raw-stop-reason.test.ts index ccd85b0a4..245c0d725 100644 --- a/packages/ai/test/google-raw-stop-reason.test.ts +++ b/packages/ai/test/google-raw-stop-reason.test.ts @@ -1,12 +1,18 @@ +import { arch, platform, release } from "node:os"; import { describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ + constructorCalls: [] as Array>, finishReason: "MALFORMED_FUNCTION_CALL", includeFunctionCall: false, })); vi.mock("@google/genai", () => { class GoogleGenAI { + constructor(config: Record) { + googleGenAiMock.constructorCalls.push(config); + } + models = { generateContentStream: async function* () { yield { @@ -84,10 +90,26 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; import { getModel } from "../src/compat.ts"; import type { Context } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; +async function captureGoogleHeaders(headers?: Record): Promise> { + googleGenAiMock.constructorCalls.length = 0; + googleGenAiMock.finishReason = "STOP"; + googleGenAiMock.includeFunctionCall = false; + await streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { + apiKey: "test-api-key", + headers, + }).result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + const httpOptions = googleGenAiMock.constructorCalls[0].httpOptions as { headers?: Record }; + return httpOptions.headers ?? {}; +} + describe("Google raw stop reasons", () => { it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => { googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL"; @@ -160,3 +182,13 @@ describe("Google raw stop reasons", () => { expect(message.content.some((block) => block.type === "toolCall")).toBe(true); }); }); + +describe("Google Generative AI user agent", () => { + it("uses pi's User-Agent by default", async () => { + expect((await captureGoogleHeaders())["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default User-Agent", async () => { + expect((await captureGoogleHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent"); + }); +}); diff --git a/packages/ai/test/google-vertex-api-key-resolution.test.ts b/packages/ai/test/google-vertex-api-key-resolution.test.ts index 46f24a773..3937b3feb 100644 --- a/packages/ai/test/google-vertex-api-key-resolution.test.ts +++ b/packages/ai/test/google-vertex-api-key-resolution.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ @@ -49,6 +50,7 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; import { getModel } from "../src/compat.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; const model = getModel("google-vertex", "gemini-3-flash-preview"); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], @@ -154,7 +156,24 @@ describe("google-vertex api key resolution", () => { await stream.result(); expect(googleGenAiMock.constructorCalls).toHaveLength(1); - expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toBeUndefined(); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({ + headers: { "User-Agent": PI_USER_AGENT }, + }); + }); + + it("lets explicit headers override the default User-Agent", async () => { + const stream = streamGoogleVertex(model, context, { + project: "test-project", + location: "us-central1", + headers: { "User-Agent": "custom-agent" }, + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({ + headers: { "User-Agent": "custom-agent" }, + }); }); it("forwards custom baseUrl to the ADC client", async () => { diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts index dde2d111e..3622fb56c 100644 --- a/packages/ai/test/mistral-http-transport.test.ts +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -1,9 +1,12 @@ +import { arch, platform, release } from "node:os"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; import { getModel } from "../src/compat.ts"; import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + function createSseResponse(events: unknown[], headers?: Record): Response { const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`; return new Response(body, { @@ -109,6 +112,7 @@ describe("Mistral HTTP transport", () => { expect(headers.get("accept")).toBe("text/event-stream"); expect(headers.get("x-affinity")).toBe("session-1"); expect(headers.get("x-custom")).toBe("value"); + expect(headers.get("user-agent")).toBe(PI_USER_AGENT); expect(callbackPayload?.maxTokens).toBe(123); expect(callbackPayload?.promptMode).toBe("reasoning"); expect(callbackPayload?.promptCacheKey).toBe("session-1"); @@ -356,11 +360,12 @@ describe("Mistral HTTP transport", () => { apiKey: "request-key", fetch, sessionId: "automatic-affinity", - headers: { authorization: null, "x-affinity": null }, + headers: { authorization: null, "x-affinity": null, "User-Agent": "custom-agent" }, }).result(); expect(requestHeaders?.has("authorization")).toBe(false); expect(requestHeaders?.has("x-affinity")).toBe(false); + expect(requestHeaders?.get("user-agent")).toBe("custom-agent"); }); it("aborts while waiting for an SSE chunk", async () => { diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 00e890815..601b5359d 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -38,6 +38,53 @@ function completedResponse(): Response { }); } +const customCompletionsModel: Model<"openai-completions"> = { + id: "grok-custom", + name: "Grok Custom", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, +}; + +async function captureCompletionsUserAgent(headers?: Record): Promise { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + const chunks = [ + { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, + { + id: "chatcmpl-ua", + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const result = await streamOpenAICompletions( + customCompletionsModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + return userAgent; +} + async function captureRequest( model: Model<"openai-responses">, context: Context, @@ -184,7 +231,7 @@ describe("xAI Responses provider", () => { }); }); - it("keeps the SDK User-Agent for non-xAI Responses requests", async () => { + it("uses pi's User-Agent by default for Responses requests", async () => { let userAgent: string | null = null; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { userAgent = new Request(input, init).headers.get("user-agent"); @@ -202,54 +249,25 @@ describe("xAI Responses provider", () => { { apiKey: "test-token" }, ).result(); - expect(result.stopReason, result.errorMessage).toBe("stop"); - expect(userAgent).not.toBeNull(); - expect(userAgent).not.toBe(PI_USER_AGENT); - }); - - it("forces pi's User-Agent on custom xAI Completions models over caller headers", async () => { - let userAgent: string | null = null; - vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { - userAgent = new Request(input, init).headers.get("user-agent"); - const chunks = [ - { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, - { - id: "chatcmpl-ua", - choices: [{ delta: {}, finish_reason: "stop", index: 0 }], - usage: { - prompt_tokens: 1, - completion_tokens: 1, - prompt_tokens_details: { cached_tokens: 0 }, - completion_tokens_details: { reasoning_tokens: 0 }, - }, - }, - ]; - const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; - return new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); - }); - - const customModel: Model<"openai-completions"> = { - id: "grok-custom", - name: "Grok Custom", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - }; - const result = await streamOpenAICompletions( - customModel, - { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, - { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, - ).result(); - expect(result.stopReason, result.errorMessage).toBe("stop"); expect(userAgent).toBe(PI_USER_AGENT); }); + + it("lets explicit headers override the default Responses User-Agent", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, + ); + + expect(captured.headers.get("user-agent")).toBe("custom-agent"); + }); + + it("uses pi's User-Agent by default for Completions requests", async () => { + expect(await captureCompletionsUserAgent()).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default Completions User-Agent", async () => { + expect(await captureCompletionsUserAgent({ "User-Agent": "custom-agent" })).toBe("custom-agent"); + }); }); From 4ca636c5e07eb1e0fbc6be6c11c720d1a8856daa Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:44:17 +0200 Subject: [PATCH 14/22] feat(ai): opeani completions reasoning details (#8246) * fix(ai): opeani completions reasoning details * cleanup: type i didnt like * changelog --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/openai-completions.ts | 136 ++++++++++++++---- packages/ai/src/types.ts | 1 + ...enai-completions-reasoning-details.test.ts | 50 +++++++ packages/server/src/protocol.ts | 1 + 5 files changed, 163 insertions(+), 26 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 324a0c6d8..49d2ce9e5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` (`reasoning.text`, `reasoning.summary`, and `reasoning.encrypted`) verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). - Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. - Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 9f89b4b24..a36ce4cb1 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -18,6 +18,7 @@ import type { ChatTemplateKwargValue, Context, ImageContent, + JsonValue, Message, Model, OpenAICompletionsCompat, @@ -128,17 +129,45 @@ function isImageContentBlock(block: { type: string }): block is ImageContent { return block.type === "image"; } -function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { - if (typeof detail !== "object" || detail === null) { +function isReasoningDetailObject(detail: unknown): detail is Record { + return typeof detail === "object" && detail !== null && !Array.isArray(detail); +} + +function hasValidCommonReasoningDetailFields(candidate: Record): boolean { + return ( + (candidate.id === undefined || candidate.id === null || typeof candidate.id === "string") && + (candidate.format === undefined || typeof candidate.format === "string") && + (candidate.index === undefined || typeof candidate.index === "number") + ); +} + +function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDetail { + if (!isReasoningDetailObject(detail) || !hasValidCommonReasoningDetailFields(detail)) { return false; } - const candidate = detail as Record; + switch (detail.type) { + case "reasoning.summary": + return typeof detail.summary === "string"; + case "reasoning.encrypted": + return typeof detail.data === "string"; + case "reasoning.text": + return ( + typeof detail.text === "string" && + (detail.signature === undefined || detail.signature === null || typeof detail.signature === "string") + ); + default: + return false; + } +} + +function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { return ( - candidate.type === "reasoning.encrypted" && - typeof candidate.id === "string" && - candidate.id.length > 0 && - typeof candidate.data === "string" && - candidate.data.length > 0 + isReasoningDetailObject(detail) && + detail.type === "reasoning.encrypted" && + typeof detail.id === "string" && + detail.id.length > 0 && + typeof detail.data === "string" && + detail.data.length > 0 ); } @@ -177,12 +206,44 @@ type KimiToolSystemMessageParam = { tools: OpenAI.Chat.Completions.ChatCompletionTool[]; }; -type OpenAIEncryptedReasoningDetail = { +type OpenAIReasoningDetailBase = Record & { + id?: string | null; + format?: string; + index?: number; +}; + +type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & { + type: "reasoning.summary"; + summary: string; +}; + +type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & { type: "reasoning.encrypted"; id: string; data: string; }; +type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & { + type: "reasoning.text"; + text: string; + signature?: string | null; +}; + +type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail; + +const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; + +type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; + +function isOpenAICompletionsReasoningField(field: string): field is OpenAICompletionsReasoningField { + return OPENAI_COMPLETIONS_REASONING_FIELDS.includes(field as OpenAICompletionsReasoningField); +} + +type ChatCompletionAssistantMessageParamWithReasoning = ChatCompletionAssistantMessageParam & + Partial> & { + reasoning_details?: JsonValue[]; + }; + type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { cache_control?: OpenAICompatCacheControl; }; @@ -555,6 +616,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { + if (!isOpenAIReasoningDetail(detail)) { + continue; + } + output.reasoningDetails ??= []; + // OpenRouter requires reasoning_details to be replayed unmodified and in order. + output.reasoningDetails.push(detail); + + // Keep the legacy encrypted tool-call attachment path for compatibility with + // sessions that replay encrypted details from toolCall.thoughtSignature. if (isEncryptedReasoningDetail(detail)) { const serializedDetail = JSON.stringify(detail); const matchingToolCall = toolCallBlocksById.get(detail.id); @@ -1150,7 +1220,7 @@ export function convertMessages( } } else if (msg.role === "assistant") { // Some providers don't accept null content, use empty string instead - const assistantMsg: ChatCompletionAssistantMessageParam = { + const assistantMsg: ChatCompletionAssistantMessageParamWithReasoning = { role: "assistant", content: compat.requiresAssistantAfterToolResult ? "" : null, }; @@ -1192,8 +1262,8 @@ export function convertMessages( if (model.provider === "opencode-go" && signature === "reasoning") { signature = "reasoning_content"; } - if (signature && signature.length > 0) { - (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + if (signature && isOpenAICompletionsReasoningField(signature)) { + assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); } } } else if (assistantText.length > 0) { @@ -1205,6 +1275,14 @@ export function convertMessages( assistantMsg.content = assistantText; } + const preservedReasoningDetails = + msg.provider === model.provider && + msg.api === model.api && + msg.model === model.id && + msg.reasoningDetails?.length + ? msg.reasoningDetails + : undefined; + const toolCalls = msg.content.filter(isToolCallBlock); if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => { @@ -1228,26 +1306,32 @@ export function convertMessages( }, }; }); - const reasoningDetails = toolCalls - .filter((tc) => tc.thoughtSignature) - .map((tc) => { - try { - return JSON.parse(tc.thoughtSignature!); - } catch { - return null; - } - }) - .filter(Boolean); - if (reasoningDetails.length > 0) { - (assistantMsg as any).reasoning_details = reasoningDetails; + if (!preservedReasoningDetails) { + const reasoningDetails = toolCalls + .filter((tc) => tc.thoughtSignature) + .map((tc): OpenAIReasoningDetail | null => { + try { + const parsed = JSON.parse(tc.thoughtSignature!) as unknown; + return isOpenAIReasoningDetail(parsed) ? parsed : null; + } catch { + return null; + } + }) + .filter((detail): detail is OpenAIReasoningDetail => detail !== null); + if (reasoningDetails.length > 0) { + assistantMsg.reasoning_details = reasoningDetails; + } } } + if (preservedReasoningDetails) { + assistantMsg.reasoning_details = preservedReasoningDetails; + } if ( compat.requiresReasoningContentOnAssistantMessages && model.reasoning && - (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + assistantMsg.reasoning_content === undefined ) { - (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + assistantMsg.reasoning_content = ""; } // Skip assistant messages that have no content and no tool calls. // Some providers require "either content or tool_calls, but not none". diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b24103819..7647226b7 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -432,6 +432,7 @@ export interface AssistantMessage { model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one + reasoningDetails?: JsonValue[]; // Provider-specific structured reasoning details to replay verbatim on later same-model turns. diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 88d428744..ae41b938f 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -38,6 +38,21 @@ vi.mock("openai", () => { }); const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" }; +const signedReasoningTextDetail = { + type: "reasoning.text", + text: "I should call the read tool.", + signature: "sha256:signed-text", + id: "reasoning-text-1", + format: "anthropic-claude-v1", + index: 0, +}; +const reasoningSummaryDetail = { + type: "reasoning.summary", + summary: "Decided to inspect the requested file.", + id: "reasoning-summary-1", + format: "anthropic-claude-v1", + index: 1, +}; const readTool: Tool = { name: "read", description: "Read a file", @@ -110,9 +125,44 @@ describe("openai-completions reasoning_details streaming", () => { arguments: { path: "README.md" }, thoughtSignature: JSON.stringify(reasoningDetail), }); + expect(assistantMessage.reasoningDetails).toEqual([reasoningDetail]); await runOpenAICompletionsStream([assistantMessage]); expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); }); + + it("falls back to encrypted tool-call signatures for older stored assistant messages", async () => { + mockState.chunkSets = [ + [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + delete assistantMessage.reasoningDetails; + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); + }); + + it("preserves signed text and summary reasoning_details in their original sequence", async () => { + mockState.chunkSets = [ + [ + chunk({ reasoning_details: [signedReasoningTextDetail] }), + chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }), + toolCallChunk(), + chunk({}, "tool_calls"), + ], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail]; + expect(assistantMessage.reasoningDetails).toEqual(expectedReasoningDetails); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + }); }); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 069828e59..08ebfbf32 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -88,6 +88,7 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "model" | "responseModel" | "responseId" + | "reasoningDetails" | "diagnostics" | "usage" | "stopReason" From 2ff8ba62238e61642fe80985175774b163f77cfa Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:48:29 +0200 Subject: [PATCH 15/22] fix(coding-agent): keep model and thinking level changes session scoped (#8356) * fix(coding-agent): keep model and thinking level changes session scoped * fix(coding-agent): persist requested default thinking level * feat: --default arg to persist * fix * fix(coding-agent): keep model and thinking level changes session scoped * fix: make default thinking level per model show list of models * cleanup export --- .../coding-agent/src/core/agent-session.ts | 90 ++++-- packages/coding-agent/src/core/defaults.ts | 9 + .../coding-agent/src/core/model-resolver.ts | 13 +- packages/coding-agent/src/core/sdk.ts | 9 +- .../coding-agent/src/core/settings-manager.ts | 28 ++ .../coding-agent/src/core/slash-commands.ts | 2 +- .../interactive/components/model-selector.ts | 6 - .../components/settings-selector.ts | 276 +++++++++++++----- .../components/settings-submenu.ts | 196 +++++++++++++ .../src/modes/interactive/interactive-mode.ts | 130 +++++++-- .../test/interactive-mode-status.test.ts | 64 +++- .../coding-agent/test/model-selector.test.ts | 1 - .../test/settings-selector.test.ts | 3 + .../agent-session-model-extension.test.ts | 108 ++++++- .../3217-scoped-model-order.test.ts | 1 - .../6999-models-json-hot-reload.test.ts | 2 - ...l-selector-filter-resets-selection.test.ts | 2 - packages/tui/src/components/settings-list.ts | 49 +++- 18 files changed, 836 insertions(+), 153 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/settings-submenu.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9f11c5511..1be95d1d1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -65,7 +65,7 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.ts"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; import { @@ -252,6 +252,12 @@ export interface PromptOptions { preflightResult?: (success: boolean) => void; } +/** Options for model/thinking mutations. */ +export interface ModelMutationOptions { + /** Persist the new value to global defaults. Defaults to session-only. */ + persist?: boolean; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -297,9 +303,6 @@ function estimateMessagesTokens(messages: AgentMessage[]): number { // Constants // ============================================================================ -/** Standard thinking levels */ -const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; - // ============================================================================ // AgentSession Class // ============================================================================ @@ -1588,21 +1591,26 @@ export class AgentSession { /** * Set model directly. - * Validates that auth is configured, saves to session and settings. + * Validates that auth is configured and saves to the session transcript. + * Persists to global defaults only when options.persist is true. * @throws Error if no auth is configured for the model */ - async setModel(model: Model): Promise { + async setModel(model: Model, options: ModelMutationOptions = {}): Promise { if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(model); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + } - // Re-clamp thinking level for new model's capabilities + // Re-clamp session thinking level for new model's capabilities. + // Per-model thinking level overrides take priority over session carry-over. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(model, previousModel, "set"); @@ -1614,14 +1622,20 @@ export class AgentSession { * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ - async cycleModel(direction: "forward" | "backward" = "forward"): Promise { + async cycleModel( + direction: "forward" | "backward" = "forward", + options: ModelMutationOptions = {}, + ): Promise { if (this._scopedModels.length > 0) { - return this._cycleScopedModel(direction); + return this._cycleScopedModel(direction, options); } - return this._cycleAvailableModel(direction); + return this._cycleAvailableModel(direction, options); } - private async _cycleScopedModel(direction: "forward" | "backward"): Promise { + private async _cycleScopedModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableIds = new Set( this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`), ); @@ -1637,17 +1651,20 @@ export class AgentSession { const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = scopedModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); + const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel); // Apply model this.agent.state.model = next.model; this.sessionManager.appendModelChange(next.model.provider, next.model.id); - this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + } - // Apply thinking level. + // Apply session thinking level. // - Explicit scoped model thinking level overrides current session level // - Undefined scoped model thinking level inherits the current session preference // setThinkingLevel clamps to model capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(next.model, currentModel, "cycle"); @@ -1655,7 +1672,10 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { + private async _cycleAvailableModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableModels = this._modelRuntime.getAvailableSnapshot(); if (availableModels.length <= 1) return undefined; @@ -1667,12 +1687,15 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel); this.agent.state.model = nextModel; this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); - this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + } - // Re-clamp thinking level for new model's capabilities + // Re-clamp session thinking level for new model's capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(nextModel, currentModel, "cycle"); @@ -1687,9 +1710,10 @@ export class AgentSession { /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. - * Saves to session and settings only if the level actually changes. + * Saves the clamped level to the session transcript only if the level actually changes. + * Persists the requested level to global defaults only when options.persist is true. */ - setThinkingLevel(level: ThinkingLevel): void { + setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); @@ -1699,11 +1723,12 @@ export class AgentSession { this.agent.state.thinkingLevel = effectiveLevel; + if (options.persist) { + this.settingsManager.setDefaultThinkingLevel(level); + } + if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); - if (this.supportsThinking() || effectiveLevel !== "off") { - this.settingsManager.setDefaultThinkingLevel(effectiveLevel); - } this._emit({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", @@ -1717,7 +1742,7 @@ export class AgentSession { * Cycle to next thinking level. * @returns New level, or undefined if model doesn't support thinking */ - cycleThinkingLevel(): ThinkingLevel | undefined { + cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined { if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); @@ -1725,7 +1750,7 @@ export class AgentSession { const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; - this.setThinkingLevel(nextLevel); + this.setThinkingLevel(nextLevel, options); return nextLevel; } @@ -1734,7 +1759,7 @@ export class AgentSession { * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels(): ThinkingLevel[] { - if (!this.model) return THINKING_LEVELS; + if (!this.model) return [...THINKING_LEVEL_OPTIONS]; return getSupportedThinkingLevels(this.model) as ThinkingLevel[]; } @@ -1745,10 +1770,17 @@ export class AgentSession { return !!this.model?.reasoning; } - private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + private _getThinkingLevelForModelSwitch(targetModel?: Model, explicitLevel?: ThinkingLevel): ThinkingLevel { if (explicitLevel !== undefined) { return explicitLevel; } + // Per-model default takes priority when switching to a model that has one + if (targetModel) { + const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id); + if (perModel !== undefined) { + return perModel; + } + } if (!this.supportsThinking()) { return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } diff --git a/packages/coding-agent/src/core/defaults.ts b/packages/coding-agent/src/core/defaults.ts index fddc7d14f..1e15c40e0 100644 --- a/packages/coding-agent/src/core/defaults.ts +++ b/packages/coding-agent/src/core/defaults.ts @@ -1,3 +1,12 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; +export const THINKING_LEVEL_OPTIONS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 80a84f963..4bf4ac541 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -626,6 +626,7 @@ export async function findInitialModel(options: { defaultProvider?: string; defaultModelId?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; modelRuntime: ModelRuntime; }): Promise { const { @@ -636,6 +637,7 @@ export async function findInitialModel(options: { defaultProvider, defaultModelId, defaultThinkingLevel, + modelThinkingLevels, modelRuntime, } = options; @@ -660,9 +662,11 @@ export async function findInitialModel(options: { // 2. Use first model from scoped models (skip if continuing/resuming) if (scopedModels.length > 0 && !isContinuing) { + const scopedModel = scopedModels[0]; + const perModel = modelThinkingLevels?.[`${scopedModel.model.provider}/${scopedModel.model.id}`]; return { - model: scopedModels[0].model, - thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, + model: scopedModel.model, + thinkingLevel: scopedModel.thinkingLevel ?? perModel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, fallbackMessage: undefined, }; } @@ -672,7 +676,10 @@ export async function findInitialModel(options: { const found = modelRuntime.getModel(defaultProvider, defaultModelId); if (found && modelRuntime.hasConfiguredAuth(found.provider)) { model = found; - if (defaultThinkingLevel) { + const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`]; + if (perModel) { + thinkingLevel = perModel; + } else if (defaultThinkingLevel) { thinkingLevel = defaultThinkingLevel; } return { model, thinkingLevel, fallbackMessage: undefined }; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a9a266418..8af041a57 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -213,6 +213,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} defaultProvider: settingsManager.getDefaultProvider(), defaultModelId: settingsManager.getDefaultModel(), defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelThinkingLevels: settingsManager.getAllModelThinkingLevels(), modelRuntime, }); model = result.model; @@ -232,7 +233,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); } - // Fall back to settings default + // Fall back to per-model override, then global default + if (thinkingLevel === undefined && model) { + const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id); + if (perModel) { + thinkingLevel = perModel; + } + } if (thinkingLevel === undefined) { thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index fa2700640..19d59483e 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -93,6 +93,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; // per-model default thinking level overrides keyed by "provider/modelId" transport?: TransportSetting; // default: "auto" steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time"; @@ -784,6 +785,33 @@ export class SettingsManager { this.save(); } + getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined { + return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`]; + } + + getAllModelThinkingLevels(): Record { + return { ...(this.settings.modelThinkingLevels ?? {}) }; + } + + setModelThinkingLevel(provider: string, modelId: string, level: ThinkingLevel): void { + if (!this.globalSettings.modelThinkingLevels) { + this.globalSettings.modelThinkingLevels = {}; + } + this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`] = level; + this.markModified("modelThinkingLevels"); + this.save(); + } + + removeModelThinkingLevel(provider: string, modelId: string): void { + if (!this.globalSettings.modelThinkingLevels) return; + delete this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`]; + if (Object.keys(this.globalSettings.modelThinkingLevels).length === 0) { + delete this.globalSettings.modelThinkingLevels; + } + this.markModified("modelThinkingLevels"); + this.save(); + } + getTransport(): TransportSetting { return this.settings.transport ?? "auto"; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 7204988fe..edf2b4987 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -18,7 +18,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, - { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] " }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 1d5e58b12..efa96ffa4 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -10,7 +10,6 @@ import { type TUI, } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; -import type { SettingsManager } from "../../../core/settings-manager.ts"; import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; @@ -52,7 +51,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private filteredModels: ModelItem[] = []; private selectedIndex: number = 0; private currentModel?: Model; - private settingsManager: SettingsManager; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; private onCancelCallback: () => void; @@ -71,7 +69,6 @@ export class ModelSelectorComponent extends Container implements Focusable { constructor( tui: TUI, currentModel: Model | undefined, - settingsManager: SettingsManager, modelRuntime: ModelRuntime, scopedModels: ReadonlyArray, onSelect: (model: Model) => void, @@ -82,7 +79,6 @@ export class ModelSelectorComponent extends Container implements Focusable { this.tui = tui; this.currentModel = currentModel; - this.settingsManager = settingsManager; this.modelRuntime = modelRuntime; this.scopedModels = scopedModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; @@ -363,8 +359,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private handleSelect(model: Model): void { this.dispose(); - // Save as new default - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.onSelectCallback(model); } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1d626270e..5fe374c50 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,13 +1,11 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Transport } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels, type Model, type Transport } from "@earendil-works/pi-ai"; import { type Component, Container, getCapabilities, type ScrollViewScrollbar, type SelectItem, - SelectList, - type SelectListLayoutOptions, type SettingItem, SettingsList, Spacer, @@ -21,20 +19,13 @@ import type { TuiMode, WarningSettings, } from "../../../core/settings-manager.ts"; -import { - getSelectListTheme, - getSettingsListTheme, - parseAutoThemeSetting, - type TerminalTheme, - theme, -} from "../theme/theme.ts"; +import { getSettingsListTheme, parseAutoThemeSetting, type TerminalTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; +import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settings-submenu.ts"; -const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { - minPrimaryColumnWidth: 12, - maxPrimaryColumnWidth: 32, -}; +const NO_DEFAULT_MODEL_VALUE = "__none__"; +const NO_DEFAULT_MODEL_LABEL = "not set"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", @@ -58,6 +49,8 @@ const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( export interface SettingsConfig { autoCompact: boolean; + defaultModel: string; + availableDefaultModels: readonly Model[]; showImages: boolean; imageWidthCells: number; autoResizeImages: boolean; @@ -69,6 +62,7 @@ export interface SettingsConfig { httpIdleTimeoutMs: number; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; + modelThinkingLevels: Record; currentTheme: string; terminalTheme: TerminalTheme; availableThemes: string[]; @@ -95,6 +89,7 @@ export interface SettingsConfig { export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; + onDefaultModelChange: (model: Model) => Promise; onShowImagesChange: (enabled: boolean) => void; onImageWidthCellsChange: (width: number) => void; onAutoResizeImagesChange: (enabled: boolean) => void; @@ -105,6 +100,8 @@ export interface SettingsCallbacks { onTransportChange: (transport: Transport) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; onThinkingLevelChange: (level: ThinkingLevel) => void; + onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void; + onModelThinkingLevelRemove: (provider: string, modelId: string) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; @@ -174,68 +171,34 @@ class WarningSettingsSubmenu extends Container { } } -class SelectSubmenu extends Container { - private selectList: SelectList; +const CLEAR_OVERRIDE_VALUE = "__clear__"; - constructor( - title: string, - description: string, - options: SelectItem[], - currentValue: string, - onSelect: (value: string) => void, - onCancel: () => void, - onSelectionChange?: (value: string) => void, - ) { - super(); +function modelSettingKey(model: Model): string { + return `${model.provider}/${model.id}`; +} - // Title - this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); +function defaultModelDisplayValue(defaultModel: string, overrides: Record): string { + const override = overrides[defaultModel]; + return override ? `${defaultModel} \u00b7 ${override}` : defaultModel; +} - // Description - if (description) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", description), 0, 0)); - } +function modelThinkingOverridesSummary(overrides: Record): string { + const count = Object.keys(overrides).length; + if (count === 0) return "none"; + return `${count} configured`; +} - // Spacer - this.addChild(new Spacer(1)); - - // Select list - this.selectList = new SelectList( - options, - Math.min(options.length, 10), - getSelectListTheme(), - SETTINGS_SUBMENU_SELECT_LIST_LAYOUT, - ); - - // Pre-select current value - const currentIndex = options.findIndex((o) => o.value === currentValue); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value); - }; - - this.selectList.onCancel = onCancel; - - if (onSelectionChange) { - this.selectList.onSelectionChange = (item) => { - onSelectionChange(item.value); - }; - } - - this.addChild(this.selectList); - - // Hint - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); - } - - handleInput(data: string): void { - this.selectList.handleInput(data); - } +function defaultModelItems(models: readonly Model[]): SelectItem[] { + return [...models] + .sort((a, b) => { + const providerCompare = a.provider.localeCompare(b.provider); + if (providerCompare !== 0) return providerCompare; + return (a.name || a.id).localeCompare(b.name || b.id); + }) + .map((model) => { + const key = modelSettingKey(model); + return { value: key, label: key, description: model.name }; + }); } function themeItems(availableThemes: string[]): SelectItem[] { @@ -493,6 +456,15 @@ export class SettingsSelectorComponent extends Container { const supportsImages = getCapabilities().images; const followUpKey = keyDisplayText("app.message.followUp"); let currentWarnings = { ...config.warnings }; + const currentModelThinkingLevels = { ...config.modelThinkingLevels }; + let lastSelectedDefaultModel: Model | undefined; + const defaultModelOptions = defaultModelItems(config.availableDefaultModels); + const defaultModelByValue = new Map( + config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), + ); + let currentDefaultModelKey: string | undefined = defaultModelByValue.has(config.defaultModel) + ? config.defaultModel + : undefined; const items: SettingItem[] = [ { @@ -524,6 +496,48 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, + { + id: "default-model", + label: "Default model", + description: "Startup model for new sessions", + currentValue: defaultModelDisplayValue(config.defaultModel, currentModelThinkingLevels), + submenu: (currentValue, done) => { + const options = + defaultModelOptions.length > 0 + ? defaultModelOptions + : [ + { + value: NO_DEFAULT_MODEL_VALUE, + label: "No models available", + description: "Log in to a provider or configure an API key first", + }, + ]; + return new SelectSubmenu( + "Default Model", + "Select the model to use when starting new sessions", + options, + currentValue === NO_DEFAULT_MODEL_LABEL ? NO_DEFAULT_MODEL_VALUE : currentValue, + (value) => { + const model = defaultModelByValue.get(value); + if (!model) { + done(); + return; + } + void callbacks.onDefaultModelChange(model).then( + () => { + lastSelectedDefaultModel = model; + currentDefaultModelKey = value; + done(defaultModelDisplayValue(value, currentModelThinkingLevels), { + navigateTo: "model-thinking", + }); + }, + () => done(), + ); + }, + () => done(), + ); + }, + }, { id: "http-idle-timeout", label: "HTTP idle timeout", @@ -612,13 +626,13 @@ export class SettingsSelectorComponent extends Container { }, { id: "thinking", - label: "Thinking level", - description: "Reasoning depth for thinking-capable models", + label: "Default thinking level", + description: "Startup reasoning depth for thinking-capable models", currentValue: config.thinkingLevel, submenu: (currentValue, done) => new SelectSubmenu( - "Thinking Level", - "Select reasoning depth for thinking-capable models", + "Default Thinking Level", + "Select the reasoning depth to use when starting new sessions", config.availableThinkingLevels.map((level) => ({ value: level, label: level, @@ -632,6 +646,116 @@ export class SettingsSelectorComponent extends Container { () => done(), ), }, + { + id: "model-thinking", + label: "Default thinking level per model", + description: "Override the default thinking level for specific models", + currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), + submenu: (_currentValue, done) => { + const preselected = lastSelectedDefaultModel; + lastSelectedDefaultModel = undefined; + + const steps: SteppedSubmenuStep[] = [ + { + key: "model", + title: "Per-Model Thinking Level", + description: "Select a model to configure", + options: () => { + const sorted = [...config.availableDefaultModels].sort((a, b) => { + const aKey = modelSettingKey(a); + const bKey = modelSettingKey(b); + if (aKey === currentDefaultModelKey) return -1; + if (bKey === currentDefaultModelKey) return 1; + const pc = a.provider.localeCompare(b.provider); + if (pc !== 0) return pc; + return (a.name || a.id).localeCompare(b.name || b.id); + }); + const items: SelectItem[] = sorted.map((model) => { + const key = modelSettingKey(model); + const override = currentModelThinkingLevels[key]; + const isDefault = key === currentDefaultModelKey; + const desc = [ + isDefault ? "default model" : undefined, + override ? `thinking: ${override}` : undefined, + ] + .filter(Boolean) + .join(" \u00b7 "); + return { value: key, label: key, description: desc || undefined }; + }); + if (items.length === 0) { + items.push({ + value: "__none__", + label: "No models available", + description: "Log in to a provider or configure an API key first", + }); + } + return items; + }, + preselect: () => currentDefaultModelKey, + }, + { + key: "level", + title: (ctx) => `Thinking Level for ${ctx.model}`, + description: "Select default thinking level for this model", + options: (ctx) => { + const model = defaultModelByValue.get(ctx.model); + if (!model) return []; + const levels = ( + model.reasoning ? getSupportedThinkingLevels(model) : ["off"] + ) as ThinkingLevel[]; + const items: SelectItem[] = levels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })); + if (currentModelThinkingLevels[ctx.model] !== undefined) { + items.push({ + value: CLEAR_OVERRIDE_VALUE, + label: "(clear override)", + description: `Revert to global default (${config.thinkingLevel})`, + }); + } + return items; + }, + preselect: (ctx) => currentModelThinkingLevels[ctx.model], + }, + ]; + + const summary = () => modelThinkingOverridesSummary(currentModelThinkingLevels); + + return new SteppedSubmenu( + steps, + (selections) => { + const model = defaultModelByValue.get(selections.model); + if (!model) return; + if (selections.level === CLEAR_OVERRIDE_VALUE) { + callbacks.onModelThinkingLevelRemove(model.provider, model.id); + delete currentModelThinkingLevels[selections.model]; + } else { + callbacks.onModelThinkingLevelChange( + model.provider, + model.id, + selections.level as ThinkingLevel, + ); + currentModelThinkingLevels[selections.model] = selections.level as ThinkingLevel; + } + }, + () => { + this.settingsList.updateValue( + "default-model", + defaultModelDisplayValue( + currentDefaultModelKey ?? config.defaultModel, + currentModelThinkingLevels, + ), + ); + done(summary()); + }, + preselected + ? { startAtStep: 1, initialContext: { model: modelSettingKey(preselected) } } + : { loop: true }, + ); + }, + }, { id: "tui-mode", label: "TUI mode", diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts new file mode 100644 index 000000000..bc6a02244 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -0,0 +1,196 @@ +import { + type Component, + Container, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; + +const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +/** + * Single-step submenu that shows a titled select list. + */ +export class SelectSubmenu extends Container { + private selectList: SelectList; + + constructor( + title: string, + description: string, + options: SelectItem[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + onSelectionChange?: (value: string) => void, + ) { + super(); + + // Title + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + + // Description + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + + // Spacer + this.addChild(new Spacer(1)); + + // Select list + this.selectList = new SelectList( + options, + Math.min(options.length, 10), + getSelectListTheme(), + SUBMENU_SELECT_LIST_LAYOUT, + ); + + // Pre-select current value + const currentIndex = options.findIndex((o) => o.value === currentValue); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = onCancel; + + if (onSelectionChange) { + this.selectList.onSelectionChange = (item) => { + onSelectionChange(item.value); + }; + } + + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +// ============================================================================ +// SteppedSubmenu — reusable multi-step selector +// ============================================================================ + +/** One step in a {@link SteppedSubmenu}. */ +export interface SteppedSubmenuStep { + /** Unique key — the selected value is stored in the result context under this key. */ + key: string; + /** Title shown at the top of the step. Receives prior selections. */ + title: string | ((context: Record) => string); + /** Description shown below the title. Receives prior selections. */ + description: string | ((context: Record) => string); + /** Build the option list for this step. Called fresh each time the step is shown. */ + options: (context: Record) => SelectItem[]; + /** Optionally pre-select a value when entering this step. */ + preselect?: (context: Record) => string | undefined; +} + +interface SteppedSubmenuOptions { + /** Start at this step index (0-based), skipping earlier steps. Requires initialContext for skipped keys. */ + startAtStep?: number; + /** Pre-fill selections for skipped steps. */ + initialContext?: Record; + /** After completing the last step, loop back to step 0 instead of closing. */ + loop?: boolean; +} + +/** + * Generic N-step submenu built on top of {@link SelectSubmenu}. + * + * Each step's options can depend on prior selections via the shared context. + * Esc goes back one step; Esc at step 0 cancels. + * With `loop: true`, completing the final step invokes `onComplete` then returns to step 0. + */ +export class SteppedSubmenu extends Container { + private readonly steps: SteppedSubmenuStep[]; + private readonly onComplete: (context: Record) => void; + private readonly onCancel: () => void; + private readonly opts: SteppedSubmenuOptions; + private activeComponent: Component; + private context: Record; + + constructor( + steps: SteppedSubmenuStep[], + onComplete: (context: Record) => void, + onCancel: () => void, + opts: SteppedSubmenuOptions = {}, + ) { + super(); + this.steps = steps; + this.onComplete = onComplete; + this.onCancel = onCancel; + this.opts = opts; + this.context = { ...(opts.initialContext ?? {}) }; + this.activeComponent = this.buildStep(opts.startAtStep ?? 0); + } + + private buildStep(stepIndex: number): Component { + const step = this.steps[stepIndex]; + const total = this.steps.length; + const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} · ` : ""; + + const title = typeof step.title === "function" ? step.title(this.context) : step.title; + const desc = typeof step.description === "function" ? step.description(this.context) : step.description; + const items = step.options(this.context); + const preselect = step.preselect?.(this.context) ?? ""; + + return new SelectSubmenu( + title, + `${stepLabel}${desc}`, + items, + preselect, + (value) => { + this.context[step.key] = value; + + if (stepIndex < total - 1) { + // Advance to next step + this.activeComponent = this.buildStep(stepIndex + 1); + } else { + // Final step — deliver result + this.onComplete({ ...this.context }); + + if (this.opts.loop) { + this.context = {}; + this.activeComponent = this.buildStep(0); + } else { + this.onCancel(); + } + } + }, + () => { + if (stepIndex > 0) { + delete this.context[step.key]; + this.activeComponent = this.buildStep(stepIndex - 1); + } else { + this.onCancel(); + } + }, + ); + } + + render(width: number): string[] { + return this.activeComponent.render(width); + } + + handleInput(data: string): void { + this.activeComponent.handleInput?.(data); + } + + invalidate(): void { + this.activeComponent.invalidate?.(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b008f0bb7..ea80883e9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -66,6 +66,7 @@ import { computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts"; import type { AutocompleteProviderFactory, EditorFactory, @@ -231,6 +232,44 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } +export interface ModelCommandArgs { + searchTerm?: string; + persist: boolean; + error?: string; +} + +export function parseModelCommandArgs(args: string | undefined): ModelCommandArgs { + if (!args?.trim()) return { persist: false }; + + let persist = false; + const searchTerms: string[] = []; + for (const token of args.trim().split(/\s+/u)) { + if (token === "--default") { + persist = true; + continue; + } + + if (token.startsWith("--default=")) { + persist = true; + const value = token.slice("--default=".length); + if (value) searchTerms.push(value); + continue; + } + + if (token.startsWith("--")) { + return { + persist, + searchTerm: searchTerms.join(" ") || undefined, + error: `Unknown /model option "${token}". Supported option: --default.`, + }; + } + + searchTerms.push(token); + } + + return { persist, searchTerm: searchTerms.join(" ") || undefined }; +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; @@ -681,6 +720,16 @@ export class InteractiveMode { const modelCommand = slashCommands.find((command) => command.name === "model"); if (modelCommand) { modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const trimmedPrefix = prefix.trimStart(); + if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { + return "--default".startsWith(trimmedPrefix) + ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] + : null; + } + + const parsed = parseModelCommandArgs(prefix); + if (parsed.error) return null; + const models = this.session.scopedModels.length > 0 ? this.session.scopedModels.map((s) => s.model) @@ -696,10 +745,11 @@ export class InteractiveMode { label: `${m.provider}/${m.id}`, })); - return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({ - value: item.label, + const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; + return createFuzzyAutocompleteItems(items, searchPrefix, getModelSearchText, (item) => ({ + value: parsed.persist ? `--default ${item.label}` : item.label, label: item.id, - description: item.provider, + description: parsed.persist ? `${item.provider} · set as startup default` : item.provider, })); }; } @@ -2950,9 +3000,13 @@ export class InteractiveMode { return; } if (text === "/model" || text.startsWith("/model ")) { - const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; + const args = parseModelCommandArgs(text.startsWith("/model ") ? text.slice(7).trim() : undefined); this.editor.setText(""); - await this.handleModelCommand(searchTerm); + if (args.error) { + this.showError(args.error); + return; + } + await this.handleModelCommand(args.searchTerm, { persist: args.persist }); return; } if (text === "/export" || text.startsWith("/export ")) { @@ -4493,9 +4547,14 @@ export class InteractiveMode { private showSettingsSelector(): void { this.showSelector((done) => { let selector: SettingsSelectorComponent | undefined; + const defaultProvider = this.settingsManager.getDefaultProvider(); + const defaultModelId = this.settingsManager.getDefaultModel(); + const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set"; selector = new SettingsSelectorComponent( { autoCompact: this.session.autoCompactionEnabled, + defaultModel, + availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(), showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), autoResizeImages: this.settingsManager.getImageAutoResize(), @@ -4505,8 +4564,9 @@ export class InteractiveMode { followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), - thinkingLevel: this.session.thinkingLevel, - availableThinkingLevels: this.session.getAvailableThinkingLevels(), + thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, + availableThinkingLevels: [...THINKING_LEVEL_OPTIONS], + modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(), currentTheme: this.themeController.getThemeSelection() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), @@ -4535,6 +4595,18 @@ export class InteractiveMode { this.session.setAutoCompactionEnabled(enabled); this.footer.setAutoCompactEnabled(enabled); }, + onDefaultModelChange: async (model) => { + try { + await this.session.setModel(model, { persist: true }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(`Default model: ${model.provider}/${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }, onShowImagesChange: (enabled) => { this.settingsManager.setShowImages(enabled); for (const child of this.chatContainer.children) { @@ -4577,10 +4649,31 @@ export class InteractiveMode { this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, onThinkingLevelChange: (level) => { - this.session.setThinkingLevel(level); + this.session.setThinkingLevel(level, { persist: true }); this.footer.invalidate(); this.updateEditorBorderColor(); }, + onModelThinkingLevelChange: (provider, modelId, level) => { + this.settingsManager.setModelThinkingLevel(provider, modelId, level); + // If the override is for the current model, apply it to the session too + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, + onModelThinkingLevelRemove: (provider, modelId) => { + this.settingsManager.removeModelThinkingLevel(provider, modelId); + // If the override was for the current model, revert to global default + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + this.session.setThinkingLevel(globalDefault); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); void this.themeController.setThemeSetting(themeSetting); @@ -4703,19 +4796,19 @@ export class InteractiveMode { }); } - private async handleModelCommand(searchTerm?: string): Promise { + private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise { if (!searchTerm) { - this.showModelSelector(); + this.showModelSelector(undefined, options); return; } const model = await this.findExactModelMatch(searchTerm); if (model) { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: options.persist === true }); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(`Model: ${model.id}`); + this.showStatus(options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -4724,7 +4817,7 @@ export class InteractiveMode { return; } - this.showModelSelector(searchTerm); + this.showModelSelector(searchTerm, options); } private async findExactModelMatch(searchTerm: string): Promise | undefined> { @@ -4852,21 +4945,22 @@ export class InteractiveMode { }); } - private showModelSelector(initialSearchInput?: string): void { + private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( this.ui, this.session.model, - this.settingsManager, this.session.modelRuntime, this.session.scopedModels, async (model) => { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: options.persist === true }); this.footer.invalidate(); this.updateEditorBorderColor(); done(); - this.showStatus(`Model: ${model.id}`); + this.showStatus( + options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`, + ); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -5558,7 +5652,7 @@ export class InteractiveMode { selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; } else { try { - await this.session.setModel(selectedModel); + await this.session.setModel(selectedModel, { persist: true }); } catch (error: unknown) { selectedModel = undefined; const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index a4aaa48c0..cd6a0b272 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { InteractiveMode, parseModelCommandArgs } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { @@ -401,6 +401,22 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { + describe("parseModelCommandArgs", () => { + test("parses --default as a persistent model selection", () => { + expect(parseModelCommandArgs("--default openai/gpt-5")).toEqual({ + persist: true, + searchTerm: "openai/gpt-5", + }); + }); + + test("rejects unknown /model flags", () => { + expect(parseModelCommandArgs("--global openai/gpt-5")).toMatchObject({ + persist: false, + error: 'Unknown /model option "--global". Supported option: --default.', + }); + }); + }); + test("matches model command arguments across provider/model order", async () => { type TestModel = { id: string; provider: string; name: string }; type FakeInteractiveMode = { @@ -452,6 +468,52 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { ]); }); + test("preserves --default when completing model command arguments", async () => { + type TestModel = { id: string; provider: string; name: string }; + type FakeInteractiveMode = { + session: { + scopedModels: Array<{ model: TestModel }>; + modelRuntime: { getAvailableSnapshot: () => TestModel[] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: null; + }; + + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const models = [{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }]; + const fakeThis: FakeInteractiveMode = { + session: { + scopedModels: [], + modelRuntime: { getAvailableSnapshot: () => models }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => "/tmp" }, + fdPath: null, + }; + + const provider = createBaseAutocompleteProvider.call(fakeThis); + const line = "/model --default codex"; + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.prefix).toBe("--default codex"); + expect(suggestions?.items[0]?.value).toBe("--default openai-codex/gpt-5.5"); + }); + test("matches login command arguments by provider id and name", async () => { type FakeInteractiveMode = { session: { diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index 060386ee7..fd5550ac1 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -34,7 +34,6 @@ describe("model selector", () => { const selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), - harness.settingsManager, harness.session.modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index e658223b9..9c88fd282 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => { fullscreenExitOutput: "transcript", fullscreenScrollbar: "auto", warnings: {}, + defaultModel: "not set", + availableDefaultModels: [], availableThinkingLevels: [], + modelThinkingLevels: {}, availableThemes: [], } as unknown as SettingsConfig; const callbacks = { diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index b334b1232..487f55e0c 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -14,7 +14,7 @@ describe("AgentSession model and extension characterization", () => { } }); - it("setModel saves the model and emits model_select", async () => { + it("setModel saves the model to the session and emits model_select", async () => { const modelEvents: string[] = []; const harness = await createHarness({ models: [ @@ -42,6 +42,112 @@ describe("AgentSession model and extension characterization", () => { .filter((entry) => entry.type === "model_change") .map((entry) => `${entry.provider}/${entry.modelId}`), ).toEqual([`${nextModel.provider}/${nextModel.id}`]); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + }); + + it("only persists model and thinking defaults when requested", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + const nextModel = harness.getModel("faux-2")!; + + await harness.session.setModel(nextModel); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + + harness.session.setThinkingLevel("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBeUndefined(); + + await harness.session.setModel(nextModel, { persist: true }); + expect(harness.settingsManager.getDefaultProvider()).toBe(nextModel.provider); + expect(harness.settingsManager.getDefaultModel()).toBe(nextModel.id); + + harness.session.setThinkingLevel("high", { persist: true }); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("high"); + }); + + it("persists the requested default thinking level even when the current model clamps it", async () => { + const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] }); + harnesses.push(harness); + + harness.session.setThinkingLevel("max", { persist: true }); + + expect(harness.session.thinkingLevel).toBe("high"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("max"); + }); + + it("cycleModel and cycleThinkingLevel are session-only by default", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultProvider: "faux", + defaultModel: "faux-1", + defaultThinkingLevel: "low", + }, + }); + harnesses.push(harness); + + await harness.session.cycleModel(); + expect(harness.session.model?.id).toBe("faux-2"); + expect(harness.settingsManager.getDefaultModel()).toBe("faux-1"); + + harness.session.setThinkingLevel("off"); + expect(harness.session.cycleThinkingLevel()).toBe("minimal"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low"); + }); + + it("applies per-model thinking level override on model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + + // Set a per-model override for faux-2 + harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low"); + + // Session starts on faux-1 with default thinking + harness.session.setThinkingLevel("high"); + expect(harness.session.thinkingLevel).toBe("high"); + + // Switch to faux-2 → per-model override should apply + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("low"); + + // Switch back to faux-1 → no per-model override, carries session level + const model1 = harness.getModel("faux-1")!; + await harness.session.setModel(model1); + expect(harness.session.thinkingLevel).toBe("low"); + }); + + it("per-model override takes priority over global default during model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultThinkingLevel: "high", + modelThinkingLevels: { "faux/faux-2": "minimal" }, + }, + }); + harnesses.push(harness); + + // Start on a non-thinking model, then switch to faux-2 + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("minimal"); }); it("cycles through scoped models and preserves the scoped thinking preference", async () => { diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index c44e1b36f..e9f33f90c 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -78,7 +78,6 @@ describe("issue #3217 scoped model ordering", () => { const selector = new ModelSelectorComponent( createFakeTui(), modelOne, - harness.settingsManager, harness.session.modelRuntime, [{ model: modelTwo }, { model: modelOne }, { model: modelThree }], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts index 8a4c144bc..2f64c52a7 100644 --- a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts +++ b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts @@ -5,7 +5,6 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../../../src/core/auth-storage.ts"; import { KeybindingsManager } from "../../../src/core/keybindings.ts"; -import { SettingsManager } from "../../../src/core/settings-manager.ts"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../../src/utils/ansi.ts"; @@ -69,7 +68,6 @@ describe("issue #6999 models.json hot reload", () => { const selector = new ModelSelectorComponent( tui, undefined, - SettingsManager.inMemory(), modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index 84551ddfb..e853ac35a 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -51,7 +51,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), current, - harness.settingsManager, harness.session.modelRuntime, [], () => {}, @@ -102,7 +101,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), alpha1, - harness.settingsManager, harness.session.modelRuntime, [{ model: alpha2 }, { model: alpha3 }, { model: alpha1 }], () => {}, diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7dce91b87..72f9aece2 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -15,8 +15,12 @@ export interface SettingItem { currentValue: string; /** If provided, Enter/Space cycles through these values */ values?: string[]; - /** If provided, Enter opens this submenu. Receives current value and done callback. */ - submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; + /** If provided, Enter opens this submenu. Receives current value and done callback. + * done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */ + submenu?: ( + currentValue: string, + done: (selectedValue?: string, options?: { navigateTo?: string }) => void, + ) => Component; } export interface SettingsListTheme { @@ -45,6 +49,7 @@ export class SettingsList implements Component { // Submenu state private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null; + private navigateAfterClose: string | null = null; constructor( items: SettingItem[], @@ -74,6 +79,15 @@ export class SettingsList implements Component { } } + /** Move selection to the item with the given id (no-op if not found). */ + selectItem(id: string): void { + const items = this.searchEnabled ? this.filteredItems : this.items; + const index = items.findIndex((i) => i.id === id); + if (index !== -1) { + this.selectedIndex = index; + } + } + invalidate(): void { this.submenuComponent?.invalidate?.(); } @@ -202,13 +216,19 @@ export class SettingsList implements Component { if (item.submenu) { // Open submenu, passing current value so it can pre-select correctly this.submenuItemIndex = this.selectedIndex; - this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { - if (selectedValue !== undefined) { - item.currentValue = selectedValue; - this.onChange(item.id, selectedValue); - } - this.closeSubmenu(); - }); + this.submenuComponent = item.submenu( + item.currentValue, + (selectedValue?: string, options?: { navigateTo?: string }) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + if (options?.navigateTo) { + this.navigateAfterClose = options.navigateTo; + } + this.closeSubmenu(); + }, + ); } else if (item.values && item.values.length > 0) { // Cycle through values const currentIndex = item.values.indexOf(item.currentValue); @@ -221,8 +241,15 @@ export class SettingsList implements Component { private closeSubmenu(): void { this.submenuComponent = null; - // Restore selection to the item that opened the submenu - if (this.submenuItemIndex !== null) { + if (this.navigateAfterClose !== null) { + const id = this.navigateAfterClose; + this.navigateAfterClose = null; + this.submenuItemIndex = null; + this.selectItem(id); + // Open the target item's submenu automatically + this.activateItem(); + } else if (this.submenuItemIndex !== null) { + // Restore selection to the item that opened the submenu this.selectedIndex = this.submenuItemIndex; this.submenuItemIndex = null; } From 98767a25d24494ea0b6acc10af4b91fc68c4cf7e Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:29:02 +0200 Subject: [PATCH 16/22] fix(settings-selector): remove token estimates --- .../modes/interactive/components/settings-selector.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 5fe374c50..c4eb594e0 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -29,11 +29,11 @@ const NO_DEFAULT_MODEL_LABEL = "not set"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", - minimal: "Very brief reasoning (~1k tokens)", - low: "Light reasoning (~2k tokens)", - medium: "Moderate reasoning (~8k tokens)", - high: "Deep reasoning (~16k tokens)", - xhigh: "Extra-high reasoning (~32k tokens)", + minimal: "Very brief reasoning", + low: "Light reasoning", + medium: "Moderate reasoning", + high: "Deep reasoning", + xhigh: "Extra-deep reasoning", max: "Maximum reasoning", }; From ee29aa118bdeb7d8c4fdafa81130e0c61f8e0423 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:43:21 +0200 Subject: [PATCH 17/22] feat(settings-selector): make default model and thinking level searchable --- .../components/settings-selector.ts | 3 + .../components/settings-submenu.ts | 109 ++++++++++++++---- 2 files changed, 88 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index c4eb594e0..f35a7852d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -535,6 +535,8 @@ export class SettingsSelectorComponent extends Container { ); }, () => done(), + undefined, + { searchable: true }, ); }, }, @@ -692,6 +694,7 @@ export class SettingsSelectorComponent extends Container { return items; }, preselect: () => currentDefaultModelKey, + searchable: true, }, { key: "level", diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts index bc6a02244..e8e6ddaf9 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -1,6 +1,9 @@ import { type Component, Container, + fuzzyFilter, + getKeybindings, + Input, type SelectItem, SelectList, type SelectListLayoutOptions, @@ -14,11 +17,23 @@ const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { maxPrimaryColumnWidth: 32, }; +export interface SelectSubmenuOptions { + /** Enable type-to-search fuzzy filtering. */ + searchable?: boolean; +} + /** * Single-step submenu that shows a titled select list. + * With `searchable: true`, typing filters the list using fuzzy matching. */ export class SelectSubmenu extends Container { private selectList: SelectList; + private listChildIndex: number; + private allOptions: SelectItem[]; + private searchInput: Input | undefined; + private onSelectCb: (value: string) => void; + private onCancelCb: () => void; + private onSelectionChangeCb?: (value: string) => void; constructor( title: string, @@ -28,9 +43,15 @@ export class SelectSubmenu extends Container { onSelect: (value: string) => void, onCancel: () => void, onSelectionChange?: (value: string) => void, + submenuOptions?: SelectSubmenuOptions, ) { super(); + this.allOptions = options; + this.onSelectCb = onSelect; + this.onCancelCb = onCancel; + this.onSelectionChangeCb = onSelectionChange; + // Title this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); @@ -40,44 +61,80 @@ export class SelectSubmenu extends Container { this.addChild(new Text(theme.fg("muted", description), 0, 0)); } + // Search input + if (submenuOptions?.searchable) { + this.addChild(new Spacer(1)); + this.searchInput = new Input(); + this.searchInput.onSubmit = () => { + this.selectList.handleInput("\r"); + }; + this.addChild(this.searchInput); + } + // Spacer this.addChild(new Spacer(1)); // Select list - this.selectList = new SelectList( + this.selectList = this.buildSelectList(options, currentValue); + this.listChildIndex = this.children.length; + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + const hint = submenuOptions?.searchable + ? " Type to filter \u00b7 Enter to select \u00b7 Esc to go back" + : " Enter to select \u00b7 Esc to go back"; + this.addChild(new Text(theme.fg("dim", hint), 0, 0)); + } + + private buildSelectList(options: SelectItem[], preselect: string): SelectList { + const list = new SelectList( options, Math.min(options.length, 10), getSelectListTheme(), SUBMENU_SELECT_LIST_LAYOUT, ); - // Pre-select current value - const currentIndex = options.findIndex((o) => o.value === currentValue); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + const idx = options.findIndex((o) => o.value === preselect); + if (idx !== -1) list.setSelectedIndex(idx); + + list.onSelect = (item) => this.onSelectCb(item.value); + list.onCancel = this.onCancelCb; + if (this.onSelectionChangeCb) { + const cb = this.onSelectionChangeCb; + list.onSelectionChange = (item) => cb(item.value); } - this.selectList.onSelect = (item) => { - onSelect(item.value); - }; + return list; + } - this.selectList.onCancel = onCancel; + private applyFilter(query: string): void { + const filtered = query + ? fuzzyFilter(this.allOptions, query, (item) => `${item.label} ${item.description ?? ""}`) + : this.allOptions; - if (onSelectionChange) { - this.selectList.onSelectionChange = (item) => { - onSelectionChange(item.value); - }; - } - - this.addChild(this.selectList); - - // Hint - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + const newList = this.buildSelectList(filtered, ""); + this.children[this.listChildIndex] = newList; + this.selectList = newList; } handleInput(data: string): void { - this.selectList.handleInput(data); + if (this.searchInput) { + const kb = getKeybindings(); + const isNav = + kb.matches(data, "tui.select.up") || + kb.matches(data, "tui.select.down") || + kb.matches(data, "tui.select.confirm") || + kb.matches(data, "tui.select.cancel"); + if (isNav) { + this.selectList.handleInput(data); + } else { + this.searchInput.handleInput(data); + this.applyFilter(this.searchInput.getValue()); + } + } else { + this.selectList.handleInput(data); + } } } @@ -87,7 +144,7 @@ export class SelectSubmenu extends Container { /** One step in a {@link SteppedSubmenu}. */ export interface SteppedSubmenuStep { - /** Unique key — the selected value is stored in the result context under this key. */ + /** Unique key \u2014 the selected value is stored in the result context under this key. */ key: string; /** Title shown at the top of the step. Receives prior selections. */ title: string | ((context: Record) => string); @@ -97,6 +154,8 @@ export interface SteppedSubmenuStep { options: (context: Record) => SelectItem[]; /** Optionally pre-select a value when entering this step. */ preselect?: (context: Record) => string | undefined; + /** Enable type-to-search fuzzy filtering for this step. */ + searchable?: boolean; } interface SteppedSubmenuOptions { @@ -141,7 +200,7 @@ export class SteppedSubmenu extends Container { private buildStep(stepIndex: number): Component { const step = this.steps[stepIndex]; const total = this.steps.length; - const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} · ` : ""; + const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} \u00b7 ` : ""; const title = typeof step.title === "function" ? step.title(this.context) : step.title; const desc = typeof step.description === "function" ? step.description(this.context) : step.description; @@ -160,7 +219,7 @@ export class SteppedSubmenu extends Container { // Advance to next step this.activeComponent = this.buildStep(stepIndex + 1); } else { - // Final step — deliver result + // Final step \u2014 deliver result this.onComplete({ ...this.context }); if (this.opts.loop) { @@ -179,6 +238,8 @@ export class SteppedSubmenu extends Container { this.onCancel(); } }, + undefined, + step.searchable ? { searchable: true } : undefined, ); } From a669db3c33851c7236315f7f5e0d028494c071f5 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:25:14 +0200 Subject: [PATCH 18/22] fix(settings-selector): show modelid [provider] like /model --- .../components/settings-selector.ts | 66 +++++++++++++------ .../components/settings-submenu.ts | 15 +++-- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index f35a7852d..a361a9df4 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -27,6 +27,8 @@ import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settin const NO_DEFAULT_MODEL_VALUE = "__none__"; const NO_DEFAULT_MODEL_LABEL = "not set"; +const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 46 }; + const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", minimal: "Very brief reasoning", @@ -177,9 +179,18 @@ function modelSettingKey(model: Model): string { return `${model.provider}/${model.id}`; } -function defaultModelDisplayValue(defaultModel: string, overrides: Record): string { - const override = overrides[defaultModel]; - return override ? `${defaultModel} \u00b7 ${override}` : defaultModel; +function modelDisplayLabel(model: Model): string { + return `${model.id} [${model.provider}]`; +} + +function defaultModelDisplayValue( + key: string, + model: Model | undefined, + overrides: Record, +): string { + const label = model ? modelDisplayLabel(model) : key; + const override = overrides[key]; + return override ? `${label} \u00b7 ${override}` : label; } function modelThinkingOverridesSummary(overrides: Record): string { @@ -188,7 +199,11 @@ function modelThinkingOverridesSummary(overrides: Record) return `${count} configured`; } -function defaultModelItems(models: readonly Model[]): SelectItem[] { +function modelItemLabel(model: Model): string { + return `${model.id} ${theme.fg("muted", `[${model.provider}]`)}`; +} + +function defaultModelItems(models: readonly Model[], overrides?: Record): SelectItem[] { return [...models] .sort((a, b) => { const providerCompare = a.provider.localeCompare(b.provider); @@ -197,7 +212,12 @@ function defaultModelItems(models: readonly Model[]): SelectItem[] { }) .map((model) => { const key = modelSettingKey(model); - return { value: key, label: key, description: model.name }; + const override = overrides?.[key]; + return { + value: key, + label: modelItemLabel(model), + description: override ?? undefined, + }; }); } @@ -458,7 +478,6 @@ export class SettingsSelectorComponent extends Container { let currentWarnings = { ...config.warnings }; const currentModelThinkingLevels = { ...config.modelThinkingLevels }; let lastSelectedDefaultModel: Model | undefined; - const defaultModelOptions = defaultModelItems(config.availableDefaultModels); const defaultModelByValue = new Map( config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), ); @@ -500,11 +519,16 @@ export class SettingsSelectorComponent extends Container { id: "default-model", label: "Default model", description: "Startup model for new sessions", - currentValue: defaultModelDisplayValue(config.defaultModel, currentModelThinkingLevels), + currentValue: defaultModelDisplayValue( + config.defaultModel, + defaultModelByValue.get(config.defaultModel), + currentModelThinkingLevels, + ), submenu: (currentValue, done) => { + const fresh = defaultModelItems(config.availableDefaultModels, currentModelThinkingLevels); const options = - defaultModelOptions.length > 0 - ? defaultModelOptions + fresh.length > 0 + ? fresh : [ { value: NO_DEFAULT_MODEL_VALUE, @@ -527,7 +551,7 @@ export class SettingsSelectorComponent extends Container { () => { lastSelectedDefaultModel = model; currentDefaultModelKey = value; - done(defaultModelDisplayValue(value, currentModelThinkingLevels), { + done(defaultModelDisplayValue(value, model, currentModelThinkingLevels), { navigateTo: "model-thinking", }); }, @@ -536,7 +560,7 @@ export class SettingsSelectorComponent extends Container { }, () => done(), undefined, - { searchable: true }, + { searchable: true, layout: MODEL_PICKER_LAYOUT }, ); }, }, @@ -675,14 +699,11 @@ export class SettingsSelectorComponent extends Container { const items: SelectItem[] = sorted.map((model) => { const key = modelSettingKey(model); const override = currentModelThinkingLevels[key]; - const isDefault = key === currentDefaultModelKey; - const desc = [ - isDefault ? "default model" : undefined, - override ? `thinking: ${override}` : undefined, - ] - .filter(Boolean) - .join(" \u00b7 "); - return { value: key, label: key, description: desc || undefined }; + return { + value: key, + label: modelItemLabel(model), + description: override ?? undefined, + }; }); if (items.length === 0) { items.push({ @@ -695,10 +716,14 @@ export class SettingsSelectorComponent extends Container { }, preselect: () => currentDefaultModelKey, searchable: true, + layout: MODEL_PICKER_LAYOUT, }, { key: "level", - title: (ctx) => `Thinking Level for ${ctx.model}`, + title: (ctx) => { + const m = defaultModelByValue.get(ctx.model); + return `Thinking Level for ${m ? modelDisplayLabel(m) : ctx.model}`; + }, description: "Select default thinking level for this model", options: (ctx) => { const model = defaultModelByValue.get(ctx.model); @@ -748,6 +773,7 @@ export class SettingsSelectorComponent extends Container { "default-model", defaultModelDisplayValue( currentDefaultModelKey ?? config.defaultModel, + defaultModelByValue.get(currentDefaultModelKey ?? config.defaultModel), currentModelThinkingLevels, ), ); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts index e8e6ddaf9..81703323a 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -20,6 +20,8 @@ const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { export interface SelectSubmenuOptions { /** Enable type-to-search fuzzy filtering. */ searchable?: boolean; + /** Override the select list layout (column widths). */ + layout?: SelectListLayoutOptions; } /** @@ -30,6 +32,7 @@ export class SelectSubmenu extends Container { private selectList: SelectList; private listChildIndex: number; private allOptions: SelectItem[]; + private listLayout: SelectListLayoutOptions; private searchInput: Input | undefined; private onSelectCb: (value: string) => void; private onCancelCb: () => void; @@ -48,6 +51,7 @@ export class SelectSubmenu extends Container { super(); this.allOptions = options; + this.listLayout = submenuOptions?.layout ?? SUBMENU_SELECT_LIST_LAYOUT; this.onSelectCb = onSelect; this.onCancelCb = onCancel; this.onSelectionChangeCb = onSelectionChange; @@ -88,12 +92,7 @@ export class SelectSubmenu extends Container { } private buildSelectList(options: SelectItem[], preselect: string): SelectList { - const list = new SelectList( - options, - Math.min(options.length, 10), - getSelectListTheme(), - SUBMENU_SELECT_LIST_LAYOUT, - ); + const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme(), this.listLayout); const idx = options.findIndex((o) => o.value === preselect); if (idx !== -1) list.setSelectedIndex(idx); @@ -156,6 +155,8 @@ export interface SteppedSubmenuStep { preselect?: (context: Record) => string | undefined; /** Enable type-to-search fuzzy filtering for this step. */ searchable?: boolean; + /** Override the select list layout (column widths) for this step. */ + layout?: SelectListLayoutOptions; } interface SteppedSubmenuOptions { @@ -239,7 +240,7 @@ export class SteppedSubmenu extends Container { } }, undefined, - step.searchable ? { searchable: true } : undefined, + step.searchable || step.layout ? { searchable: step.searchable, layout: step.layout } : undefined, ); } From f0a2880f291a0f13d6fee1fa0032dd6c61518c45 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:26:36 +0200 Subject: [PATCH 19/22] fix(settings-selector): revert token estimate removal --- .../modes/interactive/components/settings-selector.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index a361a9df4..45c696186 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -31,11 +31,11 @@ const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", - minimal: "Very brief reasoning", - low: "Light reasoning", - medium: "Moderate reasoning", - high: "Deep reasoning", - xhigh: "Extra-deep reasoning", + minimal: "Very brief reasoning (~1k tokens)", + low: "Light reasoning (~2k tokens)", + medium: "Moderate reasoning (~8k tokens)", + high: "Deep reasoning (~16k tokens)", + xhigh: "Extra-high reasoning (~32k tokens)", max: "Maximum reasoning", }; From 9c8070fbe4ef7c18feee8ab47c22c5dc1572fcd9 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:41:53 +0200 Subject: [PATCH 20/22] feat(settings-selector): ctrl + s persists /model --- .../interactive/components/model-selector.ts | 19 +++++++++++ .../components/settings-selector.ts | 5 +-- .../src/modes/interactive/interactive-mode.ts | 32 +++++++++---------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index efa96ffa4..350131850 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -5,6 +5,7 @@ import { fuzzyFilter, getKeybindings, Input, + matchesKey, Spacer, Text, type TUI, @@ -53,6 +54,7 @@ export class ModelSelectorComponent extends Container implements Focusable { private currentModel?: Model; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; + private onSelectAsDefaultCallback?: (model: Model) => void; private onCancelCallback: () => void; private errorMessage?: string; private refreshStatusMessage = "Refreshing model catalogs…"; @@ -74,6 +76,7 @@ export class ModelSelectorComponent extends Container implements Focusable { onSelect: (model: Model) => void, onCancel: () => void, initialSearchInput?: string, + onSelectAsDefault?: (model: Model) => void, ) { super(); @@ -83,6 +86,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.scopedModels = scopedModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.onSelectCallback = onSelect; + this.onSelectAsDefaultCallback = onSelectAsDefault; this.onCancelCallback = onCancel; // Add top border @@ -122,6 +126,13 @@ export class ModelSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); + // Hint + if (this.onSelectAsDefaultCallback) { + this.addChild( + new Text(theme.fg("dim", " Enter to select \u00b7 Ctrl+S to set as default \u00b7 Esc to cancel"), 0, 0), + ); + } + // Add bottom border this.addChild(new DynamicBorder()); @@ -350,6 +361,14 @@ export class ModelSelectorComponent extends Container implements Focusable { this.dispose(); this.onCancelCallback(); } + // Ctrl+S — select and save as default + else if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefaultCallback) { + const selectedModel = this.filteredModels[this.selectedIndex]; + if (selectedModel) { + this.dispose(); + this.onSelectAsDefaultCallback(selectedModel.model); + } + } // Pass everything else to search input else { this.searchInput.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 45c696186..0cfb64923 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -475,6 +475,7 @@ export class SettingsSelectorComponent extends Container { const supportsImages = getCapabilities().images; const followUpKey = keyDisplayText("app.message.followUp"); + const cycleThinkingKey = keyDisplayText("app.thinking.cycle"); let currentWarnings = { ...config.warnings }; const currentModelThinkingLevels = { ...config.modelThinkingLevels }; let lastSelectedDefaultModel: Model | undefined; @@ -653,7 +654,7 @@ export class SettingsSelectorComponent extends Container { { id: "thinking", label: "Default thinking level", - description: "Startup reasoning depth for thinking-capable models", + description: `Startup reasoning depth for thinking-capable models. ${cycleThinkingKey} cycles in-session.`, currentValue: config.thinkingLevel, submenu: (currentValue, done) => new SelectSubmenu( @@ -675,7 +676,7 @@ export class SettingsSelectorComponent extends Container { { id: "model-thinking", label: "Default thinking level per model", - description: "Override the default thinking level for specific models", + description: `Override the default thinking level for specific models. ${cycleThinkingKey} cycles in-session.`, currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), submenu: (_currentValue, done) => { const preselected = lastSelectedDefaultModel; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ea80883e9..e3f941a20 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4947,32 +4947,32 @@ export class InteractiveMode { private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void { this.showSelector((done) => { + const selectModel = async (model: Model, persist: boolean) => { + try { + await this.session.setModel(model, { persist }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + done(); + this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } catch (error) { + done(); + this.showError(error instanceof Error ? error.message : String(error)); + } + }; const selector = new ModelSelectorComponent( this.ui, this.session.model, this.session.modelRuntime, this.session.scopedModels, - async (model) => { - try { - await this.session.setModel(model, { persist: options.persist === true }); - this.footer.invalidate(); - this.updateEditorBorderColor(); - done(); - this.showStatus( - options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`, - ); - void this.maybeWarnAboutAnthropicSubscriptionAuth(model); - this.checkDaxnutsEasterEgg(model); - } catch (error) { - done(); - this.showError(error instanceof Error ? error.message : String(error)); - } - }, + (model) => selectModel(model, options.persist === true), () => { done(); this.ui.requestRender(); }, initialSearchInput, + (model) => selectModel(model, true), ); return { component: selector, focus: selector, dispose: () => selector.dispose() }; }); From 496185f6e4267b979e3663c45f7eb70b0c6a97b4 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:54:52 +0200 Subject: [PATCH 21/22] feat(coding-agent): /thinking command --- .../coding-agent/src/core/slash-commands.ts | 1 + .../components/thinking-selector.ts | 45 +++++++- .../src/modes/interactive/interactive-mode.ts | 100 ++++++++++++++++-- .../test/interactive-mode-status.test.ts | 8 +- 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index edf2b4987..d149074c2 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,6 +19,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] " }, + { name: "thinking", description: "Set thinking level", argumentHint: "[--default] " }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index bc71bafc2..07273235c 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -1,7 +1,17 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; -import { getSelectListTheme } from "../theme/theme.ts"; +import { + Container, + type Focusable, + matchesKey, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; +import { keyDisplayText } from "./keybinding-hints.ts"; const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, @@ -21,16 +31,28 @@ const LEVEL_DESCRIPTIONS: Record = { /** * Component that renders a thinking level selector with borders */ -export class ThinkingSelectorComponent extends Container { +export class ThinkingSelectorComponent extends Container implements Focusable { private selectList: SelectList; + private onSelectAsDefault?: (level: ThinkingLevel) => void; + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + } constructor( currentLevel: ThinkingLevel, availableLevels: ThinkingLevel[], onSelect: (level: ThinkingLevel) => void, onCancel: () => void, + onSelectAsDefault?: (level: ThinkingLevel) => void, ) { super(); + this.onSelectAsDefault = onSelectAsDefault; const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({ value: level, @@ -40,6 +62,11 @@ export class ThinkingSelectorComponent extends Container { // Add top border this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text("Thinking Level", 0, 0)); + this.addChild(new Spacer(1)); + this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0)); + this.addChild(new Spacer(1)); // Create selector this.selectList = new SelectList( @@ -64,11 +91,23 @@ export class ThinkingSelectorComponent extends Container { }; this.addChild(this.selectList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0)); // Add bottom border this.addChild(new DynamicBorder()); } + handleInput(keyData: string): void { + if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) { + const item = this.selectList.getSelectedItem(); + if (item) this.onSelectAsDefault(item.value as ThinkingLevel); + return; + } + + this.selectList.handleInput(keyData); + } + getSelectList(): SelectList { return this.selectList; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e3f941a20..1a1edef95 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7,7 +7,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; import type { @@ -148,6 +148,7 @@ import { type StatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; +import { ThinkingSelectorComponent } from "./components/thinking-selector.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; @@ -232,13 +233,13 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } -export interface ModelCommandArgs { +export interface DefaultFlagArgs { searchTerm?: string; persist: boolean; error?: string; } -export function parseModelCommandArgs(args: string | undefined): ModelCommandArgs { +export function parseDefaultFlagArgs(commandName: string, args: string | undefined): DefaultFlagArgs { if (!args?.trim()) return { persist: false }; let persist = false; @@ -260,7 +261,7 @@ export function parseModelCommandArgs(args: string | undefined): ModelCommandArg return { persist, searchTerm: searchTerms.join(" ") || undefined, - error: `Unknown /model option "${token}". Supported option: --default.`, + error: `Unknown /${commandName} option "${token}". Supported option: --default.`, }; } @@ -727,7 +728,7 @@ export class InteractiveMode { : null; } - const parsed = parseModelCommandArgs(prefix); + const parsed = parseDefaultFlagArgs("model", prefix); if (parsed.error) return null; const models = @@ -754,6 +755,32 @@ export class InteractiveMode { }; } + const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); + if (thinkingCommand) { + thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const trimmedPrefix = prefix.trimStart(); + if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { + return "--default".startsWith(trimmedPrefix) + ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] + : null; + } + + const parsed = parseDefaultFlagArgs("thinking", prefix); + if (parsed.error) return null; + const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; + return createFuzzyAutocompleteItems( + this.session.getAvailableThinkingLevels(), + searchPrefix, + (level) => level, + (level) => ({ + value: parsed.persist ? `--default ${level}` : level, + label: level, + description: parsed.persist ? "set as startup default" : undefined, + }), + ); + }; + } + const loginCommand = slashCommands.find((command) => command.name === "login"); if (loginCommand) { loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -3000,7 +3027,7 @@ export class InteractiveMode { return; } if (text === "/model" || text.startsWith("/model ")) { - const args = parseModelCommandArgs(text.startsWith("/model ") ? text.slice(7).trim() : undefined); + const args = parseDefaultFlagArgs("model", text.startsWith("/model ") ? text.slice(7).trim() : undefined); this.editor.setText(""); if (args.error) { this.showError(args.error); @@ -3009,6 +3036,19 @@ export class InteractiveMode { await this.handleModelCommand(args.searchTerm, { persist: args.persist }); return; } + if (text === "/thinking" || text.startsWith("/thinking ")) { + const args = parseDefaultFlagArgs( + "thinking", + text.startsWith("/thinking ") ? text.slice(10).trim() : undefined, + ); + this.editor.setText(""); + if (args.error) { + this.showError(args.error); + return; + } + this.handleThinkingCommand(args.searchTerm, { persist: args.persist }); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -4796,6 +4836,54 @@ export class InteractiveMode { }); } + private handleThinkingCommand(searchTerm?: string, options: { persist?: boolean } = {}): void { + const availableLevels = this.session.getAvailableThinkingLevels(); + if (!searchTerm) { + this.showThinkingSelector(options); + return; + } + + const normalized = searchTerm.trim().toLowerCase(); + const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized); + if (!level) { + this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`); + return; + } + + this.selectThinkingLevel(level, options.persist === true); + } + + private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { + try { + this.session.setThinkingLevel(level, { persist }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private showThinkingSelector(options: { persist?: boolean } = {}): void { + this.showSelector((done) => { + const selectLevel = (level: ThinkingLevel, persist: boolean) => { + this.selectThinkingLevel(level, persist); + done(); + }; + const selector = new ThinkingSelectorComponent( + this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL, + this.session.getAvailableThinkingLevels(), + (level) => selectLevel(level, options.persist === true), + () => { + done(); + this.ui.requestRender(); + }, + (level) => selectLevel(level, true), + ); + return { component: selector, focus: selector }; + }); + } + private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise { if (!searchTerm) { this.showModelSelector(undefined, options); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index cd6a0b272..52729e85d 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; -import { InteractiveMode, parseModelCommandArgs } from "../src/modes/interactive/interactive-mode.ts"; +import { InteractiveMode, parseDefaultFlagArgs } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { @@ -401,16 +401,16 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { - describe("parseModelCommandArgs", () => { + describe("parseDefaultFlagArgs", () => { test("parses --default as a persistent model selection", () => { - expect(parseModelCommandArgs("--default openai/gpt-5")).toEqual({ + expect(parseDefaultFlagArgs("model", "--default openai/gpt-5")).toEqual({ persist: true, searchTerm: "openai/gpt-5", }); }); test("rejects unknown /model flags", () => { - expect(parseModelCommandArgs("--global openai/gpt-5")).toMatchObject({ + expect(parseDefaultFlagArgs("model", "--global openai/gpt-5")).toMatchObject({ persist: false, error: 'Unknown /model option "--global". Supported option: --default.', }); From b7bb00b936dbe21b8e160b3e89efdec361846699 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 19 Aug 2026 23:35:43 +0200 Subject: [PATCH 22/22] fix(ai): retain reasoning details in thinking signature --- packages/ai/src/api/openai-completions.ts | 134 ++++++++---------- packages/ai/src/types.ts | 3 +- ...enai-completions-reasoning-details.test.ts | 38 +++-- packages/server/src/protocol.ts | 1 - 4 files changed, 85 insertions(+), 91 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index a36ce4cb1..08b181f23 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -160,17 +160,6 @@ function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDeta } } -function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { - return ( - isReasoningDetailObject(detail) && - detail.type === "reasoning.encrypted" && - typeof detail.id === "string" && - detail.id.length > 0 && - typeof detail.data === "string" && - detail.data.length > 0 - ); -} - export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -219,7 +208,6 @@ type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & { type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & { type: "reasoning.encrypted"; - id: string; data: string; }; @@ -231,6 +219,34 @@ type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & { type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail; +function parseOpenAIReasoningDetails(signature: string | undefined): OpenAIReasoningDetail[] | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isOpenAIReasoningDetail) ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseLegacyEncryptedReasoningDetail( + signature: string | undefined, +): OpenAIEncryptedReasoningDetail | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return isOpenAIReasoningDetail(parsed) && + parsed.type === "reasoning.encrypted" && + typeof parsed.id === "string" && + parsed.id.length > 0 && + parsed.data.length > 0 + ? parsed + : undefined; + } catch { + return undefined; + } +} + const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; @@ -341,7 +357,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio let hasFinishReason = false; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); - const pendingReasoningDetailsByToolCallId = new Map(); const blocks = output.content as StreamingBlock[]; const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); const getCustomToolCallInput = (block: StreamingToolCallBlock): string => { @@ -432,16 +447,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } return thinkingBlock; }; - const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => { - if (!block.id) { - return; - } - const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id); - if (pendingReasoningDetail) { - block.thoughtSignature = pendingReasoningDetail; - pendingReasoningDetailsByToolCallId.delete(block.id); - } - }; const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; const name = toolCall.function?.name ?? toolCall.custom?.name ?? ""; @@ -498,7 +503,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio }; delete block.partialArgs; } - applyPendingReasoningDetail(block); return block; }; @@ -616,24 +620,13 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { - if (!isOpenAIReasoningDetail(detail)) { - continue; - } - output.reasoningDetails ??= []; - // OpenRouter requires reasoning_details to be replayed unmodified and in order. - output.reasoningDetails.push(detail); - - // Keep the legacy encrypted tool-call attachment path for compatibility with - // sessions that replay encrypted details from toolCall.thoughtSignature. - if (isEncryptedReasoningDetail(detail)) { - const serializedDetail = JSON.stringify(detail); - const matchingToolCall = toolCallBlocksById.get(detail.id); - if (matchingToolCall) { - matchingToolCall.thoughtSignature = serializedDetail; - } else { - pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail); - } - } + if (!isOpenAIReasoningDetail(detail)) continue; + const block = ensureThinkingBlock(""); + const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? []; + preservedDetails.push(detail); + // Keep provider replay data in the existing signature slot. OpenRouter + // requires the complete reasoning_details sequence in its original order. + block.thinkingSignature = JSON.stringify(preservedDetails); } } } @@ -1237,9 +1230,18 @@ export function convertMessages( ); const assistantText = assistantTextParts.map((part) => part.text).join(""); - const nonEmptyThinkingBlocks = msg.content - .filter(isThinkingContentBlock) - .filter((block) => block.thinking.trim().length > 0); + const thinkingBlocks = msg.content.filter(isThinkingContentBlock); + const toolCalls = msg.content.filter(isToolCallBlock); + const signedReasoningDetails = thinkingBlocks + .map((block) => parseOpenAIReasoningDetails(block.thinkingSignature)) + .find((details) => details !== undefined); + const legacyReasoningDetails = toolCalls + .map((toolCall) => parseLegacyEncryptedReasoningDetail(toolCall.thoughtSignature)) + .filter((detail): detail is OpenAIEncryptedReasoningDetail => detail !== undefined); + const preservedReasoningDetails = + signedReasoningDetails ?? (legacyReasoningDetails.length > 0 ? legacyReasoningDetails : undefined); + + const nonEmptyThinkingBlocks = thinkingBlocks.filter((block) => block.thinking.trim().length > 0); if (nonEmptyThinkingBlocks.length > 0) { if (compat.requiresThinkingAsText) { // Convert thinking blocks to plain text (no tags to avoid model mimicking them) @@ -1257,13 +1259,16 @@ export function convertMessages( assistantMsg.content = assistantText; } - // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) - let signature = nonEmptyThinkingBlocks[0].thinkingSignature; - if (model.provider === "opencode-go" && signature === "reasoning") { - signature = "reasoning_content"; - } - if (signature && isOpenAICompletionsReasoningField(signature)) { - assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + // reasoning_details is the structured alternative to a raw reasoning field. + if (!preservedReasoningDetails) { + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && isOpenAICompletionsReasoningField(signature)) { + assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } } } } else if (assistantText.length > 0) { @@ -1275,15 +1280,6 @@ export function convertMessages( assistantMsg.content = assistantText; } - const preservedReasoningDetails = - msg.provider === model.provider && - msg.api === model.api && - msg.model === model.id && - msg.reasoningDetails?.length - ? msg.reasoningDetails - : undefined; - - const toolCalls = msg.content.filter(isToolCallBlock); if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => { const customInputProperty = options?.grammarToolInputProperties?.get(tc.name); @@ -1306,22 +1302,6 @@ export function convertMessages( }, }; }); - if (!preservedReasoningDetails) { - const reasoningDetails = toolCalls - .filter((tc) => tc.thoughtSignature) - .map((tc): OpenAIReasoningDetail | null => { - try { - const parsed = JSON.parse(tc.thoughtSignature!) as unknown; - return isOpenAIReasoningDetail(parsed) ? parsed : null; - } catch { - return null; - } - }) - .filter((detail): detail is OpenAIReasoningDetail => detail !== null); - if (reasoningDetails.length > 0) { - assistantMsg.reasoning_details = reasoningDetails; - } - } } if (preservedReasoningDetails) { assistantMsg.reasoning_details = preservedReasoningDetails; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7647226b7..d35a48b84 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -356,7 +356,7 @@ export interface TextContent { export interface ThinkingContent { type: "thinking"; thinking: string; - thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data /** When true, the thinking content was redacted by safety filters. The opaque * encrypted payload is stored in `thinkingSignature` so it can be passed back * to the API for multi-turn continuity. */ @@ -432,7 +432,6 @@ export interface AssistantMessage { model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one - reasoningDetails?: JsonValue[]; // Provider-specific structured reasoning details to replay verbatim on later same-model turns. diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index ae41b938f..723a51b79 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -99,9 +99,11 @@ async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Pr return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result(); } -function getAssistantPayload(payload: unknown): { reasoning_details?: unknown } | undefined { - const messages = (payload as { messages?: Array<{ role?: string; reasoning_details?: unknown }> }).messages ?? []; - return messages.find((message) => message.role === "assistant"); +function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined { + const messages = ( + payload as { messages?: Array<{ role?: string; reasoning?: unknown; reasoning_details?: unknown }> } + ).messages; + return messages?.find((message) => message.role === "assistant"); } describe("openai-completions reasoning_details streaming", () => { @@ -110,22 +112,26 @@ describe("openai-completions reasoning_details streaming", () => { mockState.payloads = []; }); - it("preserves reasoning_details that arrive before their matching tool call", async () => { + it("preserves reasoning_details in the thinking signature", async () => { mockState.chunkSets = [ [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], [chunk({ content: "ok" }), chunk({}, "stop")], ]; const assistantMessage = await runOpenAICompletionsStream(); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify([reasoningDetail]), + }); const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); - expect(toolCall).toMatchObject({ + expect(toolCall).toEqual({ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" }, - thoughtSignature: JSON.stringify(reasoningDetail), }); - expect(assistantMessage.reasoningDetails).toEqual([reasoningDetail]); await runOpenAICompletionsStream([assistantMessage]); @@ -139,7 +145,10 @@ describe("openai-completions reasoning_details streaming", () => { ]; const assistantMessage = await runOpenAICompletionsStream(); - delete assistantMessage.reasoningDetails; + assistantMessage.content = assistantMessage.content.filter((block) => block.type !== "thinking"); + const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); + if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected tool call"); + toolCall.thoughtSignature = JSON.stringify(reasoningDetail); await runOpenAICompletionsStream([assistantMessage]); @@ -149,7 +158,7 @@ describe("openai-completions reasoning_details streaming", () => { it("preserves signed text and summary reasoning_details in their original sequence", async () => { mockState.chunkSets = [ [ - chunk({ reasoning_details: [signedReasoningTextDetail] }), + chunk({ reasoning: signedReasoningTextDetail.text, reasoning_details: [signedReasoningTextDetail] }), chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }), toolCallChunk(), chunk({}, "tool_calls"), @@ -159,10 +168,17 @@ describe("openai-completions reasoning_details streaming", () => { const assistantMessage = await runOpenAICompletionsStream(); const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail]; - expect(assistantMessage.reasoningDetails).toEqual(expectedReasoningDetails); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: signedReasoningTextDetail.text, + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); await runOpenAICompletionsStream([assistantMessage]); - expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + const payload = getAssistantPayload(mockState.payloads[1]); + expect(payload?.reasoning_details).toEqual(expectedReasoningDetails); + expect(payload?.reasoning).toBeUndefined(); }); }); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 08ebfbf32..069828e59 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -88,7 +88,6 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "model" | "responseModel" | "responseId" - | "reasoningDetails" | "diagnostics" | "usage" | "stopReason"