diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS deleted file mode 100644 index d1f5bfea7..000000000 --- a/.github/APPROVED_CONTRIBUTORS +++ /dev/null @@ -1,247 +0,0 @@ -# GitHub handles approved to bypass contribution auto-close -# Format: -# capability: -# issue future issues stay open -# pr future issues and PRs stay open - -herrnel pr -julien-c pr -barapa pr -alasano pr -aadishv pr -airtonix pr -aliou pr -aos pr -austinm911 pr -banteg pr -ben-vargas pr -butelo pr -can1357 pr -CarlosGtrz pr -cau1k pr -cmf pr -crcatala pr -Cursivez pr -cv pr -dannote pr -default-anton pr -dnouri pr -DronNick pr -enisdenjo pr -ferologics pr -fightbulc pr -ghoulr pr -gnattu pr -HACKE-RC pr -hewliyang pr -hjanuschka pr -iamd3vil pr -jblwilliams pr -joshp123 pr -jsinge97 pr -justram pr -kaofelix pr -kiliman pr -kim0 pr -lockmeister pr -LukeFost pr -lukele pr -m-box-mr pr -marckrenn pr -markusylisiurunen pr -mcinteerj pr -melihmucuk pr -mitsuhiko pr -mrexodia pr -nathyong pr -nickseelert pr -nicobailon pr -ninlds pr -ogulcancelik pr -patrick-kidger pr -paulbettner pr -Perlence pr -pjtf93 pr -prateekmedia pr -prathamdby pr -ribelo pr -richardgill pr -robinwander pr -ronyrus pr -roshanasingh4 pr -scutifer pr -skuridin pr -steipete pr -svkozak pr -tallshort pr -theBucky pr -thomasmhr pr -tiagoefreitas pr -timolins pr -tmustier pr -tudoroancea pr -unexge pr -vaayne pr -VaclavSynacek pr -vsabavat pr -w-winter pr -Whamp pr -WismutHansen pr -XesGaDeus pr -yevhen pr -badlogictest pr -terrorobe pr -zedrdave pr -mrud pr -toorusr pr -andresaraujo pr -lightningRalf pr -williballenthin pr -masonc15 pr -4h9fbZ pr -haoqixu pr -Graffioh pr -charles-cooper pr -emanuelst pr -juanibiapina pr -liby pr -pasky pr -odysseus0 pr -giuseppeg pr -michaelpersonal pr -academo pr -PriNova pr -semtexzv pr -jasonish pr -markusn pr -SamFold pr -Soleone pr -virtuald pr -NateSmyth pr -7Sageer pr -MatthieuBizien pr -sumeet pr -marchellodev pr -vedang pr -lucemia pr -mcollina pr -lajarre pr -smithbm2316 pr -drewburr pr -gordonhwc pr -deybhayden pr -tintinweb pr -asoules pr -zhahaoyu pr -in0vik pr -jtac pr -yzhg1983 pr -smcllns pr -dmmulroy pr -zmberber pr -andresvi94 pr -sudosubin pr -Mic92 pr -pmateusz pr -wirjo pr -jay-aye-see-kay pr -lucasmeijer pr -Evizero pr - -ofa1 pr - -crisog issue - -mpazik pr - -vekexasia pr - -Michaelliv pr - -cmraible pr - -dljsjr pr - -drio pr - -jlaneve pr - -tantara pr - -Nutlope pr - -xl0 pr - -mdsjip pr - -Exrun94 pr - -marcbloech pr - -pidalf pr - -injaneity pr - -thirtythreeforty pr - -justinpbarnett pr - -cristinaponcela pr - -LooSik pr - -mchenco pr - -Phoen1xCode pr - -louis030195 pr - -technocidal pr - -pandada8 pr - -npupko issue - -chrisvariety pr - -maximilianzuern pr - -brianmichel pr - -abhinavmathur-atlan pr - -mattiacerutti pr - -josephyoung pr - -mbazso pr - -AJM10565 pr - -DanielThomas pr - -MichaelYochpaz pr - -stephanmck pr - -rolfvreijdenberger pr - -psoukie pr - -vastxie pr - -ItsumoSeito pr - -davidlifschitz pr - -vdxz pr - -dangooddd pr - -Mearman pr - -dodiego pr - -any-victor pr - -geraschenko pr diff --git a/.github/upstream.json b/.github/upstream.json index 1c10ecce1..97be96ee6 100644 --- a/.github/upstream.json +++ b/.github/upstream.json @@ -1,6 +1,6 @@ { "repo": "badlogic/pi-mono", - "tag": "v0.80.2", - "sha": "ec6311beb5b24fc918e5031173608447582d7262", - "synced_at": "2026-06-23T22:38:12Z" + "tag": "v0.80.3", + "sha": "dd87c02cbf2681c9301cf809146651483ff16030", + "synced_at": "2026-06-30T20:54:12Z" } diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml deleted file mode 100644 index d2bf830ec..000000000 --- a/.github/workflows/issue-gate.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: Issue Gate - -on: - issues: - types: [opened] - -jobs: - check-contributor: - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - name: Check issue author - uses: actions/github-script@v7 - with: - script: | - const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; - const VALID_CAPABILITIES = new Set(['issue', 'pr']); - const issueAuthor = context.payload.issue.user.login; - const defaultBranch = context.payload.repository.default_branch; - - if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') { - console.log(`Skipping bot: ${issueAuthor}`); - return; - } - - async function getPermission(username) { - try { - const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username, - }); - return permissionLevel.permission; - } catch { - return null; - } - } - - async function getTextFile(path) { - const { data: fileContent } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path, - ref: defaultBranch, - }); - - if (!('content' in fileContent) || typeof fileContent.content !== 'string') { - throw new Error(`Expected file content for ${path}`); - } - - return Buffer.from(fileContent.content, 'base64').toString('utf8'); - } - - function parseApprovedUsers(content) { - const users = new Map(); - - for (const rawLine of content.split('\n')) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) continue; - - const parts = line.split(/\s+/); - if (parts.length !== 2) { - console.log(`Skipping malformed line: ${rawLine}`); - continue; - } - - const [username, capability] = parts; - const normalizedCapability = capability.toLowerCase(); - if (!VALID_CAPABILITIES.has(normalizedCapability)) { - console.log(`Skipping line with invalid capability: ${rawLine}`); - continue; - } - - users.set(username.toLowerCase(), normalizedCapability); - } - - return users; - } - - const permission = await getPermission(issueAuthor); - if (['admin', 'maintain', 'write'].includes(permission)) { - console.log(`${issueAuthor} is a collaborator with ${permission} access`); - return; - } - - const approvedContent = await getTextFile(APPROVED_FILE); - const approvedUsers = parseApprovedUsers(approvedContent); - const capability = approvedUsers.get(issueAuthor.toLowerCase()); - - if (capability === 'issue' || capability === 'pr') { - console.log(`${issueAuthor} is approved for ${capability}`); - return; - } - - const message = [ - 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', - '', - `Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, - '', - 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.', - '', - `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: message, - }); - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: ['untriaged'], - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - state: 'closed', - state_reason: 'not_planned', - }); diff --git a/package-lock.json b/package-lock.json index fa6331d17..73045f78c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8123,32 +8123,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.80.2" + "version": "0.80.3" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.10.2", + "version": "1.10.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { "ms": "2.1.3" }, diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 507a12c37..6d509352c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,10 +4,14 @@ ### Added +- Added `prepareNextTurnWithContext` for `Agent` users that need the next-turn loop context. + ### Changed ### Fixed +- Fixed `Agent.prepareNextTurn` to keep receiving the run abort signal instead of the next-turn context. + ### Removed ## [2026.6.30] - 2026-06-30 diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index d4a293005..434882238 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -21,6 +21,7 @@ import type { AgentTool, BeforeToolCallContext, BeforeToolCallResult, + PrepareNextTurnContext, QueueMode, StreamFn, ToolExecutionMode, @@ -106,6 +107,10 @@ export interface AgentOptions { prepareNextTurn?: ( signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; + prepareNextTurnWithContext?: ( + context: PrepareNextTurnContext, + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; steeringMode?: QueueMode; followUpMode?: QueueMode; sessionId?: string; @@ -187,6 +192,10 @@ export class Agent { public prepareNextTurn?: ( signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; + public prepareNextTurnWithContext?: ( + context: PrepareNextTurnContext, + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; private activeRun?: ActiveRun; /** Session identifier forwarded to providers for cache-aware backends. */ public sessionId?: string; @@ -211,6 +220,7 @@ export class Agent { this.beforeToolCall = options.beforeToolCall; this.afterToolCall = options.afterToolCall; this.prepareNextTurn = options.prepareNextTurn; + this.prepareNextTurnWithContext = options.prepareNextTurnWithContext; this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); this.sessionId = options.sessionId; @@ -437,7 +447,15 @@ export class Agent { toolExecution: this.toolExecution, beforeToolCall: this.beforeToolCall, afterToolCall: this.afterToolCall, - prepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined, + prepareNextTurn: + this.prepareNextTurnWithContext || this.prepareNextTurn + ? async (context) => { + if (this.prepareNextTurnWithContext) { + return await this.prepareNextTurnWithContext(context, this.signal); + } + return await this.prepareNextTurn?.(this.signal); + } + : undefined, convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index e60fa2c2a..052e61c67 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -705,6 +705,47 @@ describe("Agent", () => { expect(responseCount).toBe(2); }); + it("keeps legacy prepareNextTurn signal callback behavior", async () => { + const schema = Type.Object({}); + const tool: AgentTool = { + name: "noop", + label: "Noop", + description: "Noop tool", + parameters: schema, + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }; + let requestCount = 0; + let sawAbortSignal = false; + const agent = new Agent({ + initialState: { tools: [tool] }, + prepareNextTurn: async (signal) => { + sawAbortSignal = signal instanceof AbortSignal; + return undefined; + }, + streamFn: () => { + requestCount++; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + if (requestCount === 1) { + const message = createAssistantToolUseMessage([ + { type: "toolCall", id: "tool-1", name: "noop", arguments: {} }, + ]); + stream.push({ type: "done", reason: "toolUse", message }); + return; + } + const message = createAssistantMessage("done"); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }, + }); + + await agent.prompt("start"); + + expect(requestCount).toBe(2); + expect(sawAbortSignal).toBe(true); + }); + it("forwards sessionId to streamFn options", async () => { let receivedSessionId: string | undefined; const agent = new Agent({ diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 106b52a80..71ce78cba 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,10 +4,19 @@ ### Added +- Added Anthropic Claude Sonnet 5 model metadata for Anthropic-compatible, Bedrock, OpenRouter, and Vercel AI Gateway providers. + ### Changed +- Changed OpenAI Codex Responses SSE response-header waits to use the configured HTTP timeout instead of the previous fixed 20 second timeout, reducing false timeouts on slow connections ([#4945](https://github.com/earendil-works/pi/issues/4945)). + ### Fixed +- Fixed Claude Sonnet 5 metadata to use adaptive thinking payloads for Anthropic-compatible and Bedrock requests. +- Fixed generated Xiaomi MiMo model pricing to match current pay-as-you-go pricing from models.dev ([#6138](https://github.com/earendil-works/pi/issues/6138)). +- Fixed provider HTTP errors to include response bodies instead of opaque SDK messages ([#5832](https://github.com/earendil-works/pi/pull/5832) by [@stephanmck](https://github.com/stephanmck)). +- Fixed Z.AI preserved thinking requests to send `thinking.clear_thinking: false` when thinking is enabled, allowing replayed `reasoning_content` to participate in provider caching ([#6083](https://github.com/earendil-works/pi/issues/6083)). + ### Removed ## [2026.6.30] - 2026-06-30 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3f8fe0ff8..67bb762f7 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -268,6 +268,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { modelId.includes("opus-4.8") || modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4.6") || + modelId.includes("sonnet-5") || + modelId.includes("sonnet.5") || modelId.includes("fable-5") ); } diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 4e1572fe1..cb98e712d 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -10,6 +10,7 @@ import type { StreamFunction, StreamOptions, } from "../types.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; @@ -44,19 +45,7 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: } function formatAzureOpenAIError(error: unknown): string { - if (error instanceof Error) { - const status = (error as Error & { status?: unknown }).status; - const statusCode = typeof status === "number" ? status : undefined; - if (statusCode !== undefined) { - return `Azure OpenAI API error (${statusCode}): ${error.message}`; - } - return error.message; - } - try { - return JSON.stringify(error); - } catch { - return String(error); - } + return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error"); } // Azure OpenAI Responses-specific options diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 5ea993e00..be06536ce 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -48,6 +48,7 @@ import type { ToolCall, ToolResultMessage, } from "../types.ts"; +import { normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; @@ -334,15 +335,22 @@ const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/lat * detection) can distinguish error categories via simple string matching. */ function formatBedrockError(error: unknown): string { - const message = error instanceof Error ? error.message : JSON.stringify(error); - const dataRetentionHint = /data retention mode/i.test(message) + const norm = normalizeProviderError(error); + // Surface the raw HTTP body (with status) when the SDK did not fold it into + // the message; otherwise fall back to the message. This is what stops a + // gateway 403 from collapsing to `Unknown: UnknownError`. + const core = + !norm.messageCarriesBody && norm.status !== undefined && norm.body !== undefined + ? `${norm.status}: ${norm.body}` + : norm.message; + const dataRetentionHint = /data retention mode/i.test(core) ? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.` : ""; if (error instanceof BedrockRuntimeServiceException) { const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name; - return `${prefix}: ${message}${dataRetentionHint}`; + return `${prefix}: ${core}${dataRetentionHint}`; } - return `${message}${dataRetentionHint}`; + return `${core}${dataRetentionHint}`; } /** @@ -583,6 +591,7 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-4-6") || + s.includes("sonnet-5") || s.includes("fable-5"), ); } diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 0b1f31a57..125aea8ff 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -20,6 +20,7 @@ import type { ThinkingLevel, ToolCall, } from "../types.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -307,7 +308,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( } } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatProviderError(normalizeProviderError(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index b295585db..518df82a3 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -23,6 +23,7 @@ import type { ThinkingContent, ToolCall, } from "../types.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; @@ -316,7 +317,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( } } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatProviderError(normalizeProviderError(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 1e3b3cc4e..38344a8ca 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -40,6 +40,7 @@ import { createAssistantMessageDiagnostic, formatThrownValue, } from "../utils/diagnostics.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; @@ -61,9 +62,6 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; const DEFAULT_MAX_RETRIES = 0; const BASE_DELAY_MS = 1000; const DEFAULT_MAX_RETRY_DELAY_MS = 60_000; -// Keep a bounded pre-header timeout so zero-event Codex SSE stalls fail instead of -// leaving callers stuck on "Working..." indefinitely. See #4945. -const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000; const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; @@ -185,20 +183,6 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined { return Math.floor(value); } -function createSSEHeaderTimeout(): { signal: AbortSignal; clear: () => void; error: () => Error | undefined } { - const controller = new AbortController(); - let error: Error | undefined; - const timeout = setTimeout(() => { - error = new Error(`Codex SSE response headers timed out after ${DEFAULT_SSE_HEADER_TIMEOUT_MS}ms`); - controller.abort(error); - }, DEFAULT_SSE_HEADER_TIMEOUT_MS); - return { - signal: controller.signal, - clear: () => clearTimeout(timeout), - error: () => error, - }; -} - // ============================================================================ // Main Stream Function // ============================================================================ @@ -251,7 +235,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons websocketRequestId, ); const bodyJson = JSON.stringify(body); - const idleTimeoutMs = normalizeTimeoutMs(options?.timeoutMs); + const httpTimeoutMs = normalizeTimeoutMs(options?.timeoutMs); const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs); const transport = options?.transport || "auto"; const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId); @@ -275,7 +259,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons () => { websocketStarted = true; }, - idleTimeoutMs, + httpTimeoutMs, websocketConnectTimeoutMs, options, ); @@ -331,8 +315,9 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons } try { - const headerTimeout = createSSEHeaderTimeout(); - const combinedSignal = combineAbortSignals([options?.signal, headerTimeout.signal]); + const headerTimeoutSignal = + httpTimeoutMs !== undefined && httpTimeoutMs > 0 ? AbortSignal.timeout(httpTimeoutMs) : undefined; + const combinedSignal = combineAbortSignals([options?.signal, headerTimeoutSignal]); try { response = await fetch(resolveCodexUrl(model.baseUrl), { method: "POST", @@ -341,11 +326,12 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons signal: combinedSignal.signal, }); } catch (error) { - const timeoutError = headerTimeout.error(); - throw timeoutError && !options?.signal?.aborted ? timeoutError : error; + if (headerTimeoutSignal?.aborted && !options?.signal?.aborted) { + throw new Error(`Codex SSE response headers timed out after ${httpTimeoutMs}ms`); + } + throw error; } finally { combinedSignal.cleanup(); - headerTimeout.clear(); } await options?.onResponse?.( { status: response.status, headers: headersToRecord(response.headers) }, @@ -417,7 +403,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons delete (block as { partialJson?: string }).partialJson; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : String(error); + output.errorMessage = formatProviderError(normalizeProviderError(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 08c6384e0..bb8734371 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -33,6 +33,7 @@ import type { ToolCall, ToolResultMessage, } from "../types.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; @@ -523,10 +524,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio delete (block as { streamIndex?: number }).streamIndex; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatProviderError(normalizeProviderError(error)); // Some providers via OpenRouter give additional information in this field. + // normalizeProviderError already stringifies the parsed body (error.error) + // into errorMessage, so only append the raw metadata when it is not already + // present to avoid double-printing it. const rawMetadata = getOpenRouterRawMetadata(error); - if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; + if (rawMetadata && !output.errorMessage.includes(String(rawMetadata))) { + output.errorMessage += `\n${rawMetadata}`; + } stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } @@ -659,10 +665,10 @@ function buildParams( if (compat.thinkingFormat === "zai" && model.reasoning) { const zaiParams = params as Omit & { - thinking?: { type: "enabled" | "disabled" }; + thinking?: { type: "enabled" | "disabled"; clear_thinking?: boolean }; reasoning_effort?: string; }; - zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + zaiParams.thinking = options?.reasoningEffort ? { type: "enabled", clear_thinking: false } : { type: "disabled" }; if (options?.reasoningEffort && compat.supportsReasoningEffort) { const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort]; const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort; diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 714d9b248..de212dfc4 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -15,6 +15,7 @@ import type { StreamOptions, Usage, } from "../types.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; @@ -169,19 +170,7 @@ function sanitizeUnsupportedNativeTools( } function formatOpenAIResponsesError(error: unknown): string { - if (error instanceof Error) { - const status = (error as Error & { status?: unknown }).status; - const statusCode = typeof status === "number" ? status : undefined; - if (statusCode !== undefined) { - return `OpenAI API error (${statusCode}): ${error.message}`; - } - return error.message; - } - try { - return JSON.stringify(error); - } catch { - return String(error); - } + return formatProviderError(normalizeProviderError(error), "OpenAI API error"); } // OpenAI Responses-specific options diff --git a/packages/ai/src/api/openrouter-images.ts b/packages/ai/src/api/openrouter-images.ts index 121173665..fb2772710 100644 --- a/packages/ai/src/api/openrouter-images.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -16,6 +16,7 @@ import type { ProviderHeaders, TextContent, } from "../types.ts"; +import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -99,7 +100,7 @@ export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions> return output; } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatProviderError(normalizeProviderError(error)); return output; } }; diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index bd6a1e0b2..573386613 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -155,6 +155,21 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "google/gemini-3.1-flash-lite-image": { + id: "google/gemini-3.1-flash-lite-image", + name: "Google: Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["image", "text"], + output: ["image", "text"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "microsoft/mai-image-2.5": { id: "microsoft/mai-image-2.5", name: "Microsoft: MAI-Image-2.5", @@ -455,36 +470,6 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, - "sourceful/riverflow-v2-fast-preview": { - id: "sourceful/riverflow-v2-fast-preview", - name: "Sourceful: Riverflow V2 Fast Preview", - api: "openrouter-images", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - input: ["text", "image"], - output: ["image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - } satisfies ImagesModel<"openrouter-images">, - "sourceful/riverflow-v2-max-preview": { - id: "sourceful/riverflow-v2-max-preview", - name: "Sourceful: Riverflow V2 Max Preview", - api: "openrouter-images", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - input: ["text", "image"], - output: ["image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - } satisfies ImagesModel<"openrouter-images">, "sourceful/riverflow-v2-pro": { id: "sourceful/riverflow-v2-pro", name: "Sourceful: Riverflow V2 Pro", @@ -500,21 +485,6 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, - "sourceful/riverflow-v2-standard-preview": { - id: "sourceful/riverflow-v2-standard-preview", - name: "Sourceful: Riverflow V2 Standard Preview", - api: "openrouter-images", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - input: ["text", "image"], - output: ["image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - } satisfies ImagesModel<"openrouter-images">, "sourceful/riverflow-v2.5-fast": { id: "sourceful/riverflow-v2.5-fast", name: "Sourceful: Riverflow V2.5 Fast", diff --git a/packages/ai/src/providers/amazon-bedrock.models.ts b/packages/ai/src/providers/amazon-bedrock.models.ts index f30ad72e4..9cd20c7d8 100644 --- a/packages/ai/src/providers/amazon-bedrock.models.ts +++ b/packages/ai/src/providers/amazon-bedrock.models.ts @@ -211,6 +211,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-sonnet-5": { + id: "anthropic.claude-sonnet-5", + name: "Claude Sonnet 5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "au.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (AU)", @@ -298,6 +315,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-sonnet-5": { + id: "au.anthropic.claude-sonnet-5", + name: "Claude Sonnet 5 (AU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "deepseek.r1-v1:0": { id: "deepseek.r1-v1:0", name: "DeepSeek-R1", @@ -489,6 +523,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-sonnet-5": { + id: "eu.anthropic.claude-sonnet-5", + name: "Claude Sonnet 5 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.2, + output: 11, + cacheRead: 0.22, + cacheWrite: 2.75, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "global.anthropic.claude-fable-5": { id: "global.anthropic.claude-fable-5", name: "Claude Fable 5 (Global)", @@ -629,6 +680,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-sonnet-5": { + id: "global.anthropic.claude-sonnet-5", + name: "Claude Sonnet 5 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "google.gemma-3-27b-it": { id: "google.gemma-3-27b-it", name: "Google Gemma 3 27B Instruct", @@ -733,6 +801,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "jp.anthropic.claude-sonnet-5": { + id: "jp.anthropic.claude-sonnet-5", + name: "Claude Sonnet 5 (JP)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "meta.llama3-1-70b-instruct-v1:0": { id: "meta.llama3-1-70b-instruct-v1:0", name: "Llama 3.1 70B Instruct", @@ -1538,6 +1623,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-sonnet-5": { + id: "us.anthropic.claude-sonnet-5", + name: "Claude Sonnet 5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "us.deepseek.r1-v1:0": { id: "us.deepseek.r1-v1:0", name: "DeepSeek-R1 (US)", diff --git a/packages/ai/src/providers/anthropic.models.ts b/packages/ai/src/providers/anthropic.models.ts index b95e2873c..e4b19d6b5 100644 --- a/packages/ai/src/providers/anthropic.models.ts +++ b/packages/ai/src/providers/anthropic.models.ts @@ -404,4 +404,22 @@ export const ANTHROPIC_MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "claude-sonnet-5": { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, } as const; diff --git a/packages/ai/src/providers/opencode-go.models.ts b/packages/ai/src/providers/opencode-go.models.ts index cb51ed53a..955e6c980 100644 --- a/packages/ai/src/providers/opencode-go.models.ts +++ b/packages/ai/src/providers/opencode-go.models.ts @@ -179,9 +179,9 @@ export const OPENCODE_GO_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.02, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 1000000, diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index 0b8bb914f..5c8f14914 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -369,6 +369,24 @@ export const OPENROUTER_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-5": { + id: "anthropic/claude-sonnet-5", + name: "Anthropic: Claude Sonnet 5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"thinkingFormat":"openrouter","cacheControlFormat":"anthropic"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "arcee-ai/trinity-large-thinking": { id: "arcee-ai/trinity-large-thinking", name: "Arcee AI: Trinity Large Thinking", @@ -722,13 +740,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.09, - output: 0.18, + input: 0.098, + output: 0.196, cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 65536, + maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -3066,24 +3084,6 @@ export const OPENROUTER_MODELS = { contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "openrouter/owl-alpha": { - id: "openrouter/owl-alpha", - name: "Owl Alpha", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048756, - maxTokens: 262144, - } satisfies Model<"openai-completions">, "poolside/laguna-m.1": { id: "poolside/laguna-m.1", name: "Poolside: Laguna M.1", @@ -3310,13 +3310,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.1, - output: 0.1, - cacheRead: 0.1, + input: 0.1495, + output: 1.495, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { id: "qwen/qwen3-30b-a3b", @@ -3886,8 +3886,8 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.2596, - output: 2.385, + input: 0.285, + output: 2.4, cacheRead: 0, cacheWrite: 0, }, @@ -4445,7 +4445,7 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.94, + input: 0.93, output: 3, cacheRead: 0.18, cacheWrite: 0, @@ -4535,10 +4535,10 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, }, contextWindow: 1000000, maxTokens: 128000, diff --git a/packages/ai/src/providers/vercel-ai-gateway.models.ts b/packages/ai/src/providers/vercel-ai-gateway.models.ts index 55cd5031f..21a23082f 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.models.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.models.ts @@ -691,6 +691,24 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "anthropic/claude-sonnet-5": { + id: "anthropic/claude-sonnet-5", + name: "Claude Sonnet 5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "arcee-ai/trinity-large-preview": { id: "arcee-ai/trinity-large-preview", name: "Trinity Large Preview", diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts new file mode 100644 index 000000000..c3215cdf0 --- /dev/null +++ b/packages/ai/src/utils/error-body.ts @@ -0,0 +1,127 @@ +// Shared normalization for provider HTTP error objects. +// +// Endpoints behind a proxy / gateway may return a non-2xx response whose body +// the provider SDK cannot fold into `error.message`. The SDK error object still +// carries the HTTP status and the raw/parsed body, but under SDK-specific field +// names. Provider catch blocks that read only `error.message` therefore drop +// the body and surface opaque messages like `"403 status code (no body)"` or +// collapse to `"Unknown: UnknownError"`. +// +// `normalizeProviderError` probes the known SDK field shapes (Mistral, +// `openai`, `@google/genai`, AWS Bedrock) and returns a struct each provider +// composes into its display string. The `messageCarriesBody` flag captures the +// Anthropic / `@google/genai` happy path where the SDK already folded the body +// into the message, so providers can preserve it without double-printing. + +export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000; + +export interface NormalizedProviderError { + /** HTTP status code, when one could be extracted from the SDK error object. */ + status?: number; + /** Raw HTTP body reason, already trimmed and truncated to the cap. */ + body?: string; + /** `error.message`, or `safeJsonStringify(error)` for a non-`Error` throw. */ + message: string; + /** True when `message` already contains the body (no separate body to add). */ + messageCarriesBody: boolean; +} + +type SdkErrorShape = Error & { + statusCode?: unknown; + status?: unknown; + body?: unknown; + error?: unknown; + $metadata?: { httpStatusCode?: unknown }; + $response?: { statusCode?: unknown; body?: unknown }; +}; + +export function normalizeProviderError(error: unknown): NormalizedProviderError { + if (!(error instanceof Error)) { + return { message: safeJsonStringify(error), messageCarriesBody: false }; + } + + const sdkError = error as SdkErrorShape; + const status = extractStatus(sdkError); + const body = extractBody(sdkError); + const messageCarriesBody = body === undefined || error.message.includes(body); + + return { + status, + body, + message: error.message, + messageCarriesBody, + } satisfies NormalizedProviderError; +} + +/** + * Probe the HTTP status, first numeric hit wins, in SDK-field order: + * `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) → + * `$metadata.httpStatusCode` (Bedrock) → `$response.statusCode` (Bedrock). + */ +function extractStatus(error: SdkErrorShape): number | undefined { + if (typeof error.statusCode === "number") return error.statusCode; + if (typeof error.status === "number") return error.status; + if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode; + if (typeof error.$response?.statusCode === "number") return error.$response.statusCode; + return undefined; +} + +/** + * Probe the raw body reason, first usable hit wins, in SDK-field order: + * `body` string (Mistral) → `error` parsed JSON body object (`openai` SDK's + * `this.error`) → `$response.body` (Bedrock). Empty objects are treated as no + * body so an empty parsed body does not surface as `"{}"`. The chosen body is + * truncated to the cap. + */ +function extractBody(error: SdkErrorShape): string | undefined { + const bodyText = pickBodyText(error); + if (bodyText === undefined) return undefined; + const trimmed = bodyText.trim(); + if (trimmed.length === 0) return undefined; + return truncateErrorText(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS); +} + +function pickBodyText(error: SdkErrorShape): string | undefined { + if (typeof error.body === "string") return error.body; + if (isNonEmptyObject(error.error)) return safeJsonStringify(error.error); + const responseBody = error.$response?.body; + if (typeof responseBody === "string") return responseBody; + if (isNonEmptyObject(responseBody)) return safeJsonStringify(responseBody); + return undefined; +} + +function isNonEmptyObject(value: unknown): boolean { + return typeof value === "object" && value !== null && Object.keys(value).length > 0; +} + +/** + * Compose a display string from a normalized error. When the message already + * carries the body (Anthropic / `@google/genai` happy path) or no body/status + * was extracted, the message is returned unchanged. Otherwise the status and + * body are surfaced, with an optional provider prefix. + * + * - no prefix: `": "` + * - prefix: `" (): "` + */ +export function formatProviderError(norm: NormalizedProviderError, prefix?: string): string { + if (norm.messageCarriesBody || norm.status === undefined || norm.body === undefined) { + return prefix !== undefined && norm.status !== undefined + ? `${prefix} (${norm.status}): ${norm.message}` + : norm.message; + } + return prefix !== undefined ? `${prefix} (${norm.status}): ${norm.body}` : `${norm.status}: ${norm.body}`; +} + +export function truncateErrorText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +export function safeJsonStringify(value: unknown): string { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? String(value) : serialized; + } catch { + return String(value); + } +} diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index 8d99ff18d..3f5db4762 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -5,9 +5,11 @@ import type { Api, Model } from "../src/types.ts"; const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ "anthropic/claude-fable-5", "anthropic/claude-opus-4-8", + "anthropic/claude-sonnet-5", "cloudflare-ai-gateway/claude-fable-5", "opencode/claude-opus-4-8", "vercel-ai-gateway/anthropic/claude-opus-4.8", + "vercel-ai-gateway/anthropic/claude-sonnet-5", ]; function getAllModels(): Model[] { @@ -24,7 +26,9 @@ describe("Anthropic adaptive thinking model metadata", () => { expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort())); expect(flaggedModels).toEqual( - flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|fable[-.]5)/.test(modelId)), + flaggedModels.filter((modelId) => + /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|sonnet[-.]5|fable[-.]5)/.test(modelId), + ), ); }); }); diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index e4af7a023..4c4ae7e3f 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -138,6 +138,13 @@ describe("Anthropic thinking disable payload", () => { expect(payload.output_config).toEqual({ effort: "high" }); }); + it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-sonnet-5"), { reasoning: "high" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "high" }); + }); + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => { const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "xhigh" }); diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 8fdd37f31..70b733ff5 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -112,6 +112,16 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); + it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-5"); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts new file mode 100644 index 000000000..62152f5e0 --- /dev/null +++ b/packages/ai/test/error-body.test.ts @@ -0,0 +1,154 @@ +// Unit tests for the shared provider error-body normalizer. +// +// See issues/provider-error-body-passthrough. These cover one synthesized error +// object per SDK shape (Mistral, openai APIError, @google/genai ApiError, AWS +// Bedrock ServiceException), plus the non-Error fallback, truncation, the empty +// parsed-body edge case, and the formatProviderError compose helper. + +import { describe, expect, it } from "vitest"; +import { formatProviderError, MAX_PROVIDER_ERROR_BODY_CHARS, normalizeProviderError } from "../src/utils/error-body.ts"; + +describe("normalizeProviderError", () => { + it("extracts status and body from a Mistral-shaped error", () => { + const error = Object.assign(new Error("Mistral request failed"), { + statusCode: 403, + body: '{"error":"blocked by gateway WAF"}', + }); + + const norm = normalizeProviderError(error); + + expect(norm.status).toBe(403); + expect(norm.body).toBe('{"error":"blocked by gateway WAF"}'); + expect(norm.messageCarriesBody).toBe(false); + }); + + it("reads the parsed body off an openai APIError when the message is opaque", () => { + // makeMessage(status, error, message) yields " status code (no body)" + // when the parsed body is unparsed, while the body stays on error.error. + const error = Object.assign(new Error("403 status code (no body)"), { + status: 403, + error: { error: "blocked by gateway WAF" }, + }); + + const norm = normalizeProviderError(error); + + expect(norm.status).toBe(403); + expect(norm.body).toBe('{"error":"blocked by gateway WAF"}'); + expect(norm.messageCarriesBody).toBe(false); + }); + + it("preserves the message when @google/genai already folds the body into it", () => { + const body = { error: { code: 403, message: "Permission denied" } }; + const error = Object.assign(new Error(JSON.stringify(body)), { + status: 403, + }); + + const norm = normalizeProviderError(error); + + expect(norm.status).toBe(403); + expect(norm.messageCarriesBody).toBe(true); + expect(norm.message).toBe(JSON.stringify(body)); + }); + + it("extracts status and body from a Bedrock-shaped ServiceException", () => { + const error = Object.assign(new Error("UnknownError"), { + name: "UnknownError", + $metadata: { httpStatusCode: 403 }, + $response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' }, + }); + + const norm = normalizeProviderError(error); + + expect(norm.status).toBe(403); + expect(norm.body).toBe('{"message":"blocked by gateway WAF"}'); + expect(norm.messageCarriesBody).toBe(false); + }); + + it("JSON-stringifies a non-Error thrown value", () => { + const norm = normalizeProviderError({ reason: "boom" }); + + expect(norm.status).toBeUndefined(); + expect(norm.body).toBeUndefined(); + expect(norm.message).toBe('{"reason":"boom"}'); + expect(norm.messageCarriesBody).toBe(false); + }); + + it("treats an empty parsed body object as no body", () => { + const error = Object.assign(new Error("403 status code (no body)"), { + status: 403, + error: {}, + }); + + const norm = normalizeProviderError(error); + + expect(norm.body).toBeUndefined(); + expect(norm.messageCarriesBody).toBe(true); + }); + + it("truncates the body at the cap", () => { + const longBody = "x".repeat(MAX_PROVIDER_ERROR_BODY_CHARS + 50); + const error = Object.assign(new Error("failed"), { + statusCode: 500, + body: longBody, + }); + + const norm = normalizeProviderError(error); + + expect(norm.body).toContain("... [truncated 50 chars]"); + expect(norm.body?.length).toBeLessThan(longBody.length); + }); + + it("sets messageCarriesBody when the message already contains the extracted body", () => { + const error = Object.assign(new Error("500: upstream exploded"), { + statusCode: 500, + body: "upstream exploded", + }); + + const norm = normalizeProviderError(error); + + expect(norm.messageCarriesBody).toBe(true); + }); +}); + +describe("formatProviderError", () => { + it("surfaces status and body without a prefix", () => { + const norm = normalizeProviderError( + Object.assign(new Error("403 status code (no body)"), { + status: 403, + error: { error: "blocked by gateway WAF" }, + }), + ); + + const formatted = formatProviderError(norm); + + expect(formatted).toContain("403"); + expect(formatted).toContain("blocked by gateway WAF"); + expect(formatted).not.toBe("403 status code (no body)"); + }); + + it("applies a provider prefix with status and body", () => { + const norm = normalizeProviderError( + Object.assign(new Error("403 status code (no body)"), { + status: 403, + error: { error: "blocked by gateway WAF" }, + }), + ); + + expect(formatProviderError(norm, "OpenAI API error")).toBe( + 'OpenAI API error (403): {"error":"blocked by gateway WAF"}', + ); + }); + + it("preserves the message (with prefix + status) when it already carries the body", () => { + const body = JSON.stringify({ error: { message: "Permission denied" } }); + const norm = normalizeProviderError(Object.assign(new Error(body), { status: 403 })); + + expect(formatProviderError(norm, "OpenAI API error")).toBe(`OpenAI API error (403): ${body}`); + }); + + it("returns the bare message for a non-Error value", () => { + const norm = normalizeProviderError({ reason: "boom" }); + + expect(formatProviderError(norm)).toBe('{"reason":"boom"}'); + }); +}); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 5ca504b8b..8b1783ded 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -362,8 +362,7 @@ describe("openai-codex streaming", () => { expect(result.stopReason).toBe("length"); }); - it("aborts SSE fetch when response headers do not arrive", async () => { - vi.useFakeTimers(); + it("aborts SSE fetch after the configured HTTP timeout when response headers do not arrive", async () => { const token = mockToken(); const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => { @@ -408,25 +407,15 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const resultPromise = streamOpenAICodexResponses(model, context, { + const result = await streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse", + timeoutMs: 10, }).result(); - let settled = false; - const observedResultPromise = resultPromise.then((result) => { - settled = true; - return result; - }); - await vi.advanceTimersByTimeAsync(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(10_000); - expect(settled).toBe(false); - - await vi.advanceTimersByTimeAsync(10_000); - const result = await observedResultPromise; + expect(fetchMock).toHaveBeenCalledTimes(1); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toBe("Codex SSE response headers timed out after 20000ms"); + expect(result.errorMessage).toBe("Codex SSE response headers timed out after 10ms"); }); it("aborts SSE body reads after response headers arrive", async () => { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 9f281e77b..b3548c4c3 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -422,11 +422,71 @@ describe("openai-completions tool_choice", () => { ).result(); const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; - expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.thinking).toEqual({ type: "enabled", clear_thinking: false }); expect(params.reasoning_effort).toBe(testCase.effort); } }); + it("preserves z.ai thinking when replaying reasoning_content", async () => { + const model = getModel("zai", "glm-5.2")!; + const assistantMessage: AssistantMessage = { + role: "assistant", + api: "openai-completions", + provider: "zai", + model: "glm-5.2", + content: [ + { type: "thinking", thinking: "prior reasoning", thinkingSignature: "reasoning_content" }, + { type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "contents" }], + isError: false, + timestamp: Date.now(), + }; + let payload: unknown; + + await streamSimple( + model, + { + messages: [ + { role: "user", content: "Read README.md", timestamp: Date.now() }, + assistantMessage, + toolResult, + { role: "user", content: "Continue", timestamp: Date.now() }, + ], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { + messages?: Array>; + thinking?: unknown; + }; + const replayedAssistant = params.messages?.find((message) => message.role === "assistant"); + expect(replayedAssistant).toMatchObject({ reasoning_content: "prior reasoning" }); + expect(params.thinking).toEqual({ type: "enabled", clear_thinking: false }); + }); + it("omits z.ai GLM-5.2 reasoning_effort when thinking is off", async () => { const model = getModel("zai", "glm-5.2")!; let payload: unknown; diff --git a/packages/ai/test/provider-error-body-passthrough.test.ts b/packages/ai/test/provider-error-body-passthrough.test.ts new file mode 100644 index 000000000..6b3c981ab --- /dev/null +++ b/packages/ai/test/provider-error-body-passthrough.test.ts @@ -0,0 +1,78 @@ +// Regression test for issues/provider-error-body-passthrough +// +// When an endpoint behind a proxy / gateway returns a non-2xx response with a +// body the SDK cannot fold into its message, the provider catch block drops the +// body. The openai SDK's APIError keeps the parsed body on `error.error` and +// produces `" status code (no body)"` as the message, so a body-blind +// catch (`error.message` only) surfaces the opaque message and hides the real +// reason the gateway returned. +// +// This test routes a 403-with-body APIError through the OpenRouter image +// provider (one of the body-blind providers) and asserts the resulting +// errorMessage contains both the status and the body reason. It is EXPECTED TO +// FAIL until the provider catch blocks read the SDK error body. + +import { describe, expect, it, vi } from "vitest"; +import { generateImages } from "../src/images.ts"; +import type { ImagesContext, ImagesModel } from "../src/types.ts"; + +// Reproduce the openai SDK APIError shape: makeMessage(status, error, message) +// returns `"403 status code (no body)"` when status is set but the parsed body +// (`error`) is empty/unparsed, while the parsed body itself is kept on `.error`. +class FakeAPIError extends Error { + status: number; + error: unknown; + constructor(status: number, parsedBody: unknown) { + super(`${status} status code (no body)`); + this.name = "PermissionDeniedError"; + this.status = status; + this.error = parsedBody; + } +} + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: () => { + const promise = Promise.resolve(undefined) as unknown as { + withResponse: () => Promise; + }; + promise.withResponse = async () => { + // 403 from a gateway/proxy carrying the real reason in the body. + throw new FakeAPIError(403, { error: "blocked by gateway WAF" }); + }; + return promise; + }, + }, + }; + } + return { default: FakeOpenAI }; +}); + +describe("provider error body passthrough", () => { + it("surfaces the HTTP body reason instead of the opaque SDK message (openrouter images)", async () => { + const model: ImagesModel<"openrouter-images"> = { + id: "black-forest-labs/flux.2-pro", + name: "FLUX.2 Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { input: 0.015, output: 0.03, cacheRead: 0, cacheWrite: 0 }, + }; + const context: ImagesContext = { + input: [{ type: "text", text: "Generate a dog" }], + }; + + const output = await generateImages(model, context, { apiKey: "test" }); + + expect(output.stopReason).toBe("error"); + // The status should be surfaced. + expect(output.errorMessage).toContain("403"); + // The body reason must not be swallowed by the opaque SDK message. + expect(output.errorMessage).toContain("blocked by gateway WAF"); + expect(output.errorMessage).not.toBe("403 status code (no body)"); + }); +}); diff --git a/packages/ai/test/provider-error-body-regression.test.ts b/packages/ai/test/provider-error-body-regression.test.ts new file mode 100644 index 000000000..5649bba6c --- /dev/null +++ b/packages/ai/test/provider-error-body-regression.test.ts @@ -0,0 +1,189 @@ +// Per-tier provider regression for issues/provider-error-body-passthrough. +// +// Routes a 403-with-body error through the real provider catch path for one +// representative per tier (Success Criterion 7): a body-blind text provider +// (openai-completions), a status-only provider (openai-responses), and a +// body-blind Bedrock provider. Each asserts the resulting errorMessage carries +// both the HTTP status and the body reason. The image-provider tier is covered +// by provider-error-body-passthrough.test.ts; the already-correct happy path +// (no double body / no duplicated status) is asserted via the shared helper in +// error-body.test.ts. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamSimple as streamSimpleBedrock } from "../src/api/bedrock-converse-stream.ts"; +import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; +import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; +import type { Context, Model } from "../src/types.ts"; + +// openai SDK APIError shape: " status code (no body)" message, the +// parsed body kept on `.error`. +class FakeAPIError extends Error { + status: number; + error: unknown; + constructor(status: number, parsedBody: unknown) { + super(`${status} status code (no body)`); + this.name = "PermissionDeniedError"; + this.status = status; + this.error = parsedBody; + } +} + +const bedrockMock = vi.hoisted(() => ({ + sendError: undefined as unknown, +})); + +const openaiMock = vi.hoisted(() => ({ + // Default parsed body; individual tests may override before invoking. + parsedBody: { error: "blocked by gateway WAF" } as unknown, +})); + +vi.mock("openai", () => { + function throwingCreate() { + const promise = Promise.resolve(undefined) as unknown as { withResponse: () => Promise }; + promise.withResponse = async () => { + throw new FakeAPIError(403, openaiMock.parsedBody); + }; + return promise; + } + class FakeOpenAI { + chat = { completions: { create: throwingCreate } }; + responses = { create: throwingCreate }; + } + return { default: FakeOpenAI }; +}); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + middlewareStack = { add: () => {} }; + send(): Promise { + return Promise.reject(bedrockMock.sendError); + } + } + + 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 { getModel } from "../src/compat.ts"; + +const context: Context = { + systemPrompt: "", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }], + tools: [], +}; + +const completionsModel: Model<"openai-completions"> = { + id: "test-model", + name: "Test Model", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, +}; + +const responsesModel: Model<"openai-responses"> = { + id: "gpt-test", + name: "GPT Test", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, +}; + +async function drainResult(stream: { + [Symbol.asyncIterator](): AsyncIterator; + result(): Promise<{ errorMessage?: string; stopReason?: string }>; +}) { + for await (const _event of stream) { + void _event; + } + return stream.result(); +} + +describe("provider error body passthrough (per-tier regression)", () => { + beforeEach(() => { + openaiMock.parsedBody = { error: "blocked by gateway WAF" }; + }); + + it("openai-completions (body-blind text) surfaces status + body", async () => { + const output = await drainResult(streamOpenAICompletions(completionsModel, context, { apiKey: "test" })); + + expect(output.stopReason).toBe("error"); + expect(output.errorMessage).toContain("403"); + expect(output.errorMessage).toContain("blocked by gateway WAF"); + expect(output.errorMessage).not.toBe("403 status code (no body)"); + }); + + it("openai-completions does not double-print the OpenRouter metadata.raw extra", async () => { + // OpenRouter returns the extra reason under error.error.metadata.raw, which + // is part of the parsed body normalizeProviderError already surfaces. The + // manual append must not duplicate it. + openaiMock.parsedBody = { + message: "Provider returned error", + code: 403, + metadata: { raw: "upstream WAF blocked policy XYZ" }, + }; + + const output = await drainResult(streamOpenAICompletions(completionsModel, context, { apiKey: "test" })); + + expect(output.errorMessage).toContain("upstream WAF blocked policy XYZ"); + const occurrences = output.errorMessage?.match(/upstream WAF blocked policy XYZ/g) ?? []; + expect(occurrences).toHaveLength(1); + }); + + it("openai-responses (status-only) keeps the prefix and surfaces the body", async () => { + const output = await drainResult(streamOpenAIResponses(responsesModel, context, { apiKey: "test" })); + + expect(output.stopReason).toBe("error"); + expect(output.errorMessage).toContain("OpenAI API error (403)"); + expect(output.errorMessage).toContain("blocked by gateway WAF"); + }); + + it("bedrock (body-blind) surfaces the gateway body instead of Unknown: UnknownError", async () => { + bedrockMock.sendError = Object.assign(new Error("UnknownError"), { + name: "UnknownError", + $metadata: { httpStatusCode: 403 }, + $response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' }, + }); + + const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const output = await drainResult(streamSimpleBedrock(model, { messages: context.messages }, {})); + + expect(output.stopReason).toBe("error"); + expect(output.errorMessage).toContain("403"); + expect(output.errorMessage).toContain("blocked by gateway WAF"); + expect(output.errorMessage).not.toContain("Unknown: UnknownError"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6cf38670a..0f4851217 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,10 +4,26 @@ ### Added +- Added inherited Anthropic Claude Sonnet 5 model support. +- Added `get_entries` and `get_tree` RPC commands for reading session entries and tree snapshots over RPC ([#6078](https://github.com/earendil-works/pi/pull/6078) by [@geraschenko](https://github.com/geraschenko)). +- Added session-name change events for extensions ([#6175](https://github.com/earendil-works/pi/pull/6175) by [@xl0](https://github.com/xl0)). +- Added an `outputPad` setting for user message, assistant message, and thinking horizontal padding ([#6168](https://github.com/earendil-works/pi/issues/6168)). + ### Changed +- Changed inherited OpenAI Codex Responses SSE response-header waits to use the configured HTTP timeout instead of the previous fixed 20 second timeout, reducing false timeouts on slow connections ([#4945](https://github.com/earendil-works/pi/issues/4945)). + ### Fixed +- Fixed inherited Claude Sonnet 5 metadata to use adaptive thinking payloads for Anthropic-compatible and Bedrock requests. +- Fixed inherited generated Xiaomi MiMo model pricing to match current pay-as-you-go pricing from models.dev ([#6138](https://github.com/earendil-works/pi/issues/6138)). +- Fixed inherited provider HTTP errors to include response bodies instead of opaque SDK messages ([#5832](https://github.com/earendil-works/pi/pull/5832) by [@stephanmck](https://github.com/stephanmck)). +- Fixed inherited Z.AI preserved thinking requests to send `thinking.clear_thinking: false` when thinking is enabled, allowing replayed `reasoning_content` to participate in provider caching ([#6083](https://github.com/earendil-works/pi/issues/6083)). +- Fixed pre-prompt compaction to stop after compaction instead of continuing immediately ([#6074](https://github.com/earendil-works/pi/pull/6074) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed extension tool changes to apply before the next provider request in the same agent run without dropping `before_agent_start` system-prompt overrides ([#6162](https://github.com/earendil-works/pi/issues/6162)). +- Fixed a crash when undici emits an internal client error while terminating a mid-stream HTTP response ([#6133](https://github.com/earendil-works/pi/issues/6133)). +- Fixed interactive status indicators so ending work, retry, compaction, or branch-summary indicators no longer shrink the TUI when clear-on-shrink is enabled ([#6026](https://github.com/earendil-works/pi/pull/6026)). + ## [2026.6.30] - 2026-06-30 ### Added diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index e5c7bfea3..5f4a1368c 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -327,6 +327,9 @@ user sends another prompt ◄───────────────── ├─► session_start { reason: "fork", previousSessionFile } └─► resources_discover { reason: "startup" } +/name or pi.setSessionName() + └─► session_info_changed + /compact or auto-compaction ├─► session_before_compact (can cancel or customize) └─► session_compact @@ -845,6 +848,17 @@ pi.on("session_start", async (event, ctx) => { }); ``` +#### session_info_changed + +Fired when the current session display name is set via `/name`, RPC, or `pi.setSessionName()`. + +```typescript +pi.on("session_info_changed", async (event, ctx) => { + // event.name - current normalized name, or undefined if cleared + ctx.ui.notify(`Session renamed: ${event.name ?? "(none)"}`, "info"); +}); +``` + #### session_before_switch Fired before starting a new session (`/new`) or switching sessions (`/resume`). diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 1c2b2278f..a2c83fda3 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -661,6 +661,64 @@ Response: } ``` +#### get_entries + +Get all session entries in append order (excluding the session header). The session is an append-only tree of entries with stable ids, so an entry id works as a durable cursor: pass the last entry id you have seen as `since` to get only entries strictly after it, even across client restarts. Unlike `get_messages`, this includes pre-compaction history and abandoned branches. + +```json +{"type": "get_entries"} +``` + +With a cursor: +```json +{"type": "get_entries", "since": "abc123"} +``` + +Response: +```json +{ + "type": "response", + "command": "get_entries", + "success": true, + "data": { + "entries": [ + {"type": "message", "id": "def456", "parentId": "abc123", "timestamp": "...", "message": {"role": "user", "...": "..."}} + ], + "leafId": "def456" + } +} +``` + +`leafId` is the id of the current leaf entry (`null` for an empty session), so a client can tell in one round trip whether the active branch moved. If `since` does not match any entry id, the response is `success: false`. + +#### get_tree + +Get the session as a tree of entries. Each node is `{entry, children, label?, labelTimestamp?}`. A well-formed session has a single root; orphaned entries (broken parent chain) also appear as roots. + +```json +{"type": "get_tree"} +``` + +Response: +```json +{ + "type": "response", + "command": "get_tree", + "success": true, + "data": { + "tree": [ + { + "entry": {"type": "message", "id": "abc123", "parentId": null, "...": "..."}, + "children": [ + {"entry": {"type": "message", "id": "def456", "parentId": "abc123", "...": "..."}, "children": []} + ] + } + ], + "leafId": "def456" + } +} +``` + #### get_last_assistant_text Get the text content of the last assistant message. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3f10e5a65..8ca2f7238 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -129,6 +129,7 @@ When this value is anything other than `"auto"`, it overrides any model-level `p | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | +| `outputPad` | number | `1` | Horizontal padding for user messages, assistant messages, and thinking (0 or 1) | | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 907489ab4..557f3e367 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.80.2", + "version": "0.80.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 4820c2891..80cdd3149 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.80.2", + "version": "0.80.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 38f4684a3..721ad1573 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.80.2", + "version": "0.80.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index f3c54fa1c..7ce46a06c 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.80.2", + "version": "0.80.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 9cbb476ec..dfdb93430 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.80.2", + "version": "0.80.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 715288532..f08fb03a0 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.10.2", + "version": "1.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.10.2", + "version": "1.10.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index f3cc2ac42..66f97d0ac 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.10.2", + "version": "1.10.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 0eb595031..95f73a1b7 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.80.2", + "version": "0.80.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 53e2272fa..bdfe6233e 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.80.2", + "version": "0.80.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e59e10dad..f69bd2896 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -22,6 +22,7 @@ import type { AgentMessage, AgentState, AgentTool, + PrepareNextTurnContext, ThinkingLevel, } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai/compat"; @@ -398,6 +399,7 @@ export class AgentSession { private _baseSystemPrompt = ""; private _currentServiceTier: ServiceTier | undefined = undefined; private _baseSystemPromptOptions!: BuildDynamicSystemPromptOptions; + private _systemPromptOverride?: string; constructor(config: AgentSessionConfig) { this.agent = config.agent; @@ -424,19 +426,7 @@ export class AgentSession { this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); - const previousPrepareNextTurn = this.agent.prepareNextTurn; - this.agent.prepareNextTurn = async (signal) => { - const nextTurn = await previousPrepareNextTurn?.(signal); - const model = this.agent.state.model; - if (!model) { - return nextTurn; - } - return { - ...nextTurn, - model, - thinkingLevel: this.agent.state.thinkingLevel, - }; - }; + this._installAgentNextTurnRefresh(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, @@ -553,6 +543,29 @@ export class AgentSession { }; } + private _installAgentNextTurnRefresh(): void { + const previousPrepareNextTurnWithContext = + this.agent.prepareNextTurnWithContext ?? + (this.agent.prepareNextTurn + ? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal) + : undefined); + this.agent.prepareNextTurnWithContext = async (turn, signal) => { + const previousSnapshot = await previousPrepareNextTurnWithContext?.(turn, signal); + const previousContext = previousSnapshot?.context ?? turn.context; + + return { + ...previousSnapshot, + context: { + ...previousContext, + systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt, + tools: this.agent.state.tools.slice(), + }, + model: this.agent.state.model, + thinkingLevel: this.agent.state.thinkingLevel, + }; + }; + } + // ========================================================================= // Event Subscription // ========================================================================= @@ -1029,7 +1042,7 @@ export class AgentSession { // Rebuild base system prompt with new tool set this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); - this.agent.state.systemPrompt = this._baseSystemPrompt; + this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt; } /** Whether compaction or branch summarization is currently running */ @@ -1289,7 +1302,8 @@ export class AgentSession { throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); } - // Check if we need to compact before sending (catches aborted responses) + // Check if we need to compact before sending (catches aborted responses). + // The user's new prompt is sent below, so do not call agent.continue() here. const lastAssistant = this._findLastAssistantMessage(); if (lastAssistant) { await this._checkCompaction(lastAssistant, false, "pre_prompt"); @@ -1336,10 +1350,12 @@ export class AgentSession { } } // Apply extension-modified system prompt, or reset to base - if (result?.systemPrompt) { + if (result?.systemPrompt !== undefined) { + this._systemPromptOverride = result.systemPrompt; this.agent.state.systemPrompt = result.systemPrompt; } else { // Ensure we're using the base prompt (in case previous turn had modifications) + this._systemPromptOverride = undefined; this.agent.state.systemPrompt = this._baseSystemPrompt; } } catch (error) { @@ -2313,7 +2329,7 @@ export class AgentSession { this._incrementMessageRevision(); } if (requestReason) { - await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck); + await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, "overflow", willRetry); } else { await this._runAutoCompaction("overflow", willRetry); } @@ -2357,14 +2373,16 @@ export class AgentSession { private async _runPrePromptCompaction( lastAssistantMessage: AssistantMessage, skipAbortedCheck: boolean, + reason: "pre_prompt" | "overflow" = "pre_prompt", + willRetry = false, ): Promise { - this._emit({ type: "compaction_start", reason: "pre_prompt" }); + this._emit({ type: "compaction_start", reason }); this._compactionAbortController = new AbortController(); try { const execution = await this._executeCompaction({ - reason: "pre_prompt", - willRetry: false, + reason, + willRetry, lastAssistantMessage, skipAbortedCheck, }); @@ -2380,10 +2398,10 @@ export class AgentSession { errorMessage === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); this._emit({ type: "compaction_end", - reason: "pre_prompt", + reason, result: undefined, aborted, - willRetry: false, + willRetry, errorMessage: aborted ? undefined : `Pre-prompt compaction failed: ${errorMessage}`, }); } finally { @@ -3274,7 +3292,9 @@ export class AgentSession { */ setSessionName(name: string): void { this.sessionManager.appendSessionInfo(name); - this._emit({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); + const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() } as const; + this._emit(event); + void this._extensionRunner.emit(event); } // ========================================================================= diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 3b088ba9c..2f93f52e9 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -133,6 +133,7 @@ export type { SessionBeforeTreeResult, SessionCompactEvent, SessionEvent, + SessionInfoChangedEvent, SessionShutdownEvent, // Events - Session SessionStartEvent, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 1d37e5805..f40cf3f77 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -601,6 +601,13 @@ export interface SessionStartEvent { previousSessionFile?: string; } +/** Fired when the current session metadata changes. */ +export interface SessionInfoChangedEvent { + type: "session_info_changed"; + /** Current normalized session name. Undefined when the name is cleared. */ + name: string | undefined; +} + /** Fired before switching to another session (can be cancelled) */ export interface SessionBeforeSwitchEvent { type: "session_before_switch"; @@ -692,6 +699,7 @@ export interface SessionTreeEvent { export type SessionEvent = | SessionStartEvent + | SessionInfoChangedEvent | SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionBeforeCompactEvent @@ -1218,6 +1226,7 @@ export interface ExtensionAPI { on(event: "project_trust", handler: ProjectTrustHandler): void; on(event: "resources_discover", handler: ExtensionHandler): void; on(event: "session_start", handler: ExtensionHandler): void; + on(event: "session_info_changed", handler: ExtensionHandler): void; on( event: "session_before_switch", handler: ExtensionHandler, diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts index 0910f4d6c..e4ad8909c 100644 --- a/packages/coding-agent/src/core/http-dispatcher.ts +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import * as undici from "undici"; export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000; @@ -46,18 +47,50 @@ export function applyHttpProxySettings(httpProxy: string | undefined): void { process.env.HTTPS_PROXY ??= proxy; } +const ignoreUndiciDispatcherError = (_error: unknown): void => {}; + +// Undici can emit an internal Client "error" while terminating a mid-stream +// fetch body. The body stream still rejects through reader.read(); this listener +// only prevents EventEmitter's unhandled "error" special case from crashing pi. +function withUndiciErrorListener(dispatcher: T): T { + if (dispatcher instanceof EventEmitter) { + EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError); + } + return dispatcher; +} + +function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher { + return withUndiciErrorListener(new undici.Client(origin, options as undici.Client.Options)); +} + +function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher { + const dispatcherOptions = options as undici.Pool.Options; + if (dispatcherOptions.connections === 1) { + return createUndiciClient(origin, dispatcherOptions); + } + return withUndiciErrorListener( + new undici.Pool(origin, { + ...dispatcherOptions, + factory: createUndiciClient, + }), + ); +} + export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); if (normalizedTimeoutMs === undefined) { throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); } - undici.setGlobalDispatcher( + const dispatcher = withUndiciErrorListener( new undici.EnvHttpProxyAgent({ allowH2: false, bodyTimeout: normalizedTimeoutMs, headersTimeout: normalizedTimeoutMs, + clientFactory: createUndiciClient, + factory: createUndiciOriginDispatcher, }), ); + undici.setGlobalDispatcher(dispatcher); // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's // bundled fetch can otherwise consume compressed responses through npm undici's // dispatcher without decompressing them, causing response.json() failures. diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 1150f9a65..806d8b738 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -130,6 +130,7 @@ export interface Settings { treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels editorPaddingX?: number; // Horizontal padding for input editor (default: 0) + outputPad?: 0 | 1; // Horizontal padding for chat message output (default: 1) autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; @@ -1242,6 +1243,16 @@ export class SettingsManager { this.save(); } + getOutputPad(): 0 | 1 { + return this.settings.outputPad === 0 ? 0 : 1; + } + + setOutputPad(padding: 0 | 1): void { + this.globalSettings.outputPad = padding; + this.markModified("outputPad"); + this.save(); + } + getAutocompleteMaxVisible(): number { return this.settings.autocompleteMaxVisible ?? 5; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 5830ecdad..1b64d97a9 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -122,6 +122,7 @@ export type { SessionBeforeSwitchEvent, SessionBeforeTreeEvent, SessionCompactEvent, + SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, @@ -224,6 +225,7 @@ export { type SessionInfoEntry, SessionManager, type SessionMessageEntry, + type SessionTreeNode, type ThinkingLevelChangeEntry, } from "./core/session-manager.ts"; export { diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 306dcc1ca..df9a050f7 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -19,6 +19,7 @@ export class AssistantMessageComponent extends Container { private hideThinkingBlock: boolean; private markdownTheme: MarkdownTheme; private hiddenThinkingLabel: string; + private outputPad: number; private lastMessage?: AssistantMessage; private lastMessageSignature?: string; private hasToolCalls = false; @@ -29,12 +30,14 @@ export class AssistantMessageComponent extends Container { hideThinkingBlock = false, markdownTheme: MarkdownTheme = getMarkdownTheme(), hiddenThinkingLabel = "Thinking...", + outputPad = 1, ) { super(); this.hideThinkingBlock = hideThinkingBlock; this.markdownTheme = markdownTheme; this.hiddenThinkingLabel = hiddenThinkingLabel; + this.outputPad = outputPad; // Container for text/thinking content this.contentContainer = new Container(); @@ -83,6 +86,14 @@ export class AssistantMessageComponent extends Container { } } + setOutputPad(padding: number): void { + this.outputPad = padding; + if (this.lastMessage) { + this.lastMessageSignature = undefined; + this.updateContent(this.lastMessage); + } + } + override render(width: number): string[] { const signature = this.lastMessageSignature ?? ""; if (this.cachedLines && this.cachedWidth === width && this.cachedSignature === signature) { @@ -131,7 +142,7 @@ export class AssistantMessageComponent extends Container { if (content.type === "text" && content.text.trim()) { // Assistant text messages with no background - trim the text // Set paddingY=0 to avoid extra spacing before tool executions - this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, this.markdownTheme)); + this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme)); } else if (content.type === "thinking" && content.thinking.trim()) { // Add spacing only when another visible assistant content block follows. // This avoids a superfluous blank line before separately-rendered tool execution blocks. @@ -142,7 +153,7 @@ export class AssistantMessageComponent extends Container { if (this.hideThinkingBlock) { // Show static thinking label when hidden this.contentContainer.addChild( - new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0), + new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0), ); if (hasVisibleContentAfter) { this.contentContainer.addChild(new Spacer(1)); @@ -150,7 +161,7 @@ export class AssistantMessageComponent extends Container { } else { // Thinking traces in thinkingText color, italic this.contentContainer.addChild( - new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, { + new Markdown(content.thinking.trim(), this.outputPad, 0, this.markdownTheme, { color: (text: string) => theme.fg("thinkingText", text), italic: true, }), @@ -193,7 +204,7 @@ export class AssistantMessageComponent extends Container { "error", "Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.", ), - 1, + this.outputPad, 0, ), ); @@ -204,11 +215,11 @@ export class AssistantMessageComponent extends Container { ? message.errorMessage : "Operation aborted"; this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.outputPad, 0)); } else if (message.stopReason === "error") { const errorMsg = message.errorMessage || "Unknown error"; this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), this.outputPad, 0)); } } } 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 beef3bbb7..425e2f1a1 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -72,6 +72,7 @@ export interface SettingsConfig { treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; showHardwareCursor: boolean; editorPaddingX: number; + outputPad: 0 | 1; autocompleteMaxVisible: number; quietStartup: boolean; defaultProjectTrust: DefaultProjectTrust; @@ -101,6 +102,7 @@ export interface SettingsCallbacks { onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void; onShowHardwareCursorChange: (enabled: boolean) => void; onEditorPaddingXChange: (padding: number) => void; + onOutputPadChange: (padding: 0 | 1) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; @@ -677,9 +679,19 @@ export class SettingsSelectorComponent extends Container { values: ["0", "1", "2", "3"], }); - // Autocomplete max visible toggle (insert after editor-padding) + // Output padding toggle (insert after editor-padding) const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding"); items.splice(editorPaddingIndex + 1, 0, { + id: "output-padding", + label: "Output padding", + description: "Horizontal padding for user messages, assistant messages, and thinking", + currentValue: String(config.outputPad), + values: ["0", "1"], + }); + + // Autocomplete max visible toggle (insert after output-padding) + const outputPaddingIndex = items.findIndex((item) => item.id === "output-padding"); + items.splice(outputPaddingIndex + 1, 0, { id: "autocomplete-max-visible", label: "Autocomplete max items", description: "Max visible items in autocomplete dropdown (3-20)", @@ -783,6 +795,9 @@ export class SettingsSelectorComponent extends Container { case "editor-padding": callbacks.onEditorPaddingXChange(parseInt(newValue, 10)); break; + case "output-padding": + callbacks.onOutputPadChange(newValue === "0" ? 0 : 1); + break; case "autocomplete-max-visible": callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10)); break; diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts new file mode 100644 index 000000000..2cc87d293 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -0,0 +1,119 @@ +import { type Component, Loader, type LoaderIndicatorOptions, type TUI } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; +import { CountdownTimer } from "./countdown-timer.ts"; +import { keyText } from "./keybinding-hints.ts"; + +export type StatusIndicatorKind = "working" | "retry" | "compaction" | "branchSummary"; + +export class StatusIndicator extends Loader { + readonly kind: StatusIndicatorKind; + + constructor( + kind: StatusIndicatorKind, + ui: TUI, + spinnerColorFn: (str: string) => string, + messageColorFn: (str: string) => string, + message: string, + indicator?: LoaderIndicatorOptions, + ) { + super(ui, spinnerColorFn, messageColorFn, message, indicator); + this.kind = kind; + } + + dispose(): void { + this.stop(); + } +} + +export class WorkingStatusIndicator extends StatusIndicator { + constructor(ui: TUI, message: string, indicator?: LoaderIndicatorOptions) { + super( + "working", + ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + message, + indicator, + ); + } +} + +export class RetryStatusIndicator extends StatusIndicator { + private countdown: CountdownTimer | undefined; + + constructor(ui: TUI, attempt: number, maxAttempts: number, delayMs: number) { + const retryMessage = (seconds: number) => + `Retrying (${attempt}/${maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`; + super( + "retry", + ui, + (spinner) => theme.fg("warning", spinner), + (text) => theme.fg("muted", text), + retryMessage(Math.ceil(delayMs / 1000)), + ); + this.countdown = new CountdownTimer( + delayMs, + ui, + (seconds) => { + this.setMessage(retryMessage(seconds)); + }, + () => { + this.countdown = undefined; + }, + ); + } + + override dispose(): void { + this.countdown?.dispose(); + this.countdown = undefined; + super.dispose(); + } +} + +export type CompactionStatusReason = "manual" | "threshold" | "overflow" | "pre_prompt" | "branch" | "extension"; + +export class CompactionStatusIndicator extends StatusIndicator { + constructor(ui: TUI, reason: CompactionStatusReason) { + const cancelHint = `(${keyText("app.interrupt")} to cancel)`; + const label = + reason === "manual" + ? `Compacting context... ${cancelHint}` + : reason === "overflow" + ? `Context overflow detected, compacting... ${cancelHint}` + : reason === "pre_prompt" + ? `Compacting before next prompt... ${cancelHint}` + : reason === "threshold" + ? `Auto-compacting... ${cancelHint}` + : `Compacting context... ${cancelHint}`; + super( + "compaction", + ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + label, + ); + } +} + +export class BranchSummaryStatusIndicator extends StatusIndicator { + constructor(ui: TUI) { + super( + "branchSummary", + ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, + ); + } +} + +export class IdleStatus implements Component { + invalidate(): void { + // No cached state to invalidate. + } + + render(width: number): string[] { + const emptyLine = " ".repeat(width); + return [emptyLine, emptyLine]; + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index d1b393430..74b54fd76 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -9,24 +9,39 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07"; * Component that renders a user message */ export class UserMessageComponent extends Container { - private contentBox: Box; + private text: string; + private markdownTheme: MarkdownTheme; + private outputPad: number; - constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) { super(); - this.contentBox = new Box(1, 1, (content: string) => theme.bg("userMessageBg", content)); - this.contentBox.addChild( + this.text = text; + this.markdownTheme = markdownTheme; + this.outputPad = outputPad; + this.rebuild(); + } + + setOutputPad(padding: number): void { + this.outputPad = padding; + this.rebuild(); + } + + private rebuild(): void { + this.clear(); + const contentBox = new Box(this.outputPad, 1, (content: string) => theme.bg("userMessageBg", content)); + contentBox.addChild( new Markdown( - text, + this.text, 0, 0, - markdownTheme, + this.markdownTheme, { color: (content: string) => theme.fg("userMessageText", content), }, { preserveOrderedListMarkers: true, preserveBackslashEscapes: true }, ), ); - this.addChild(this.contentBox); + this.addChild(contentBox); } override render(width: number): string[] { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 138acc127..93fa2294d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -36,7 +36,6 @@ import { fuzzyFilter, getCapabilities, hyperlink, - Loader, type LoaderIndicatorOptions, Markdown, matchesKey, @@ -73,6 +72,7 @@ import type { ExtensionUIDialogOptions, ExtensionWidgetOptions, ProjectTrustContext, + WorkingIndicatorOptions, } from "../../core/extensions/index.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; @@ -108,7 +108,6 @@ import { BashExecutionComponent } from "./components/bash-execution.ts"; import { BorderedLoader } from "./components/bordered-loader.ts"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; -import { CountdownTimer } from "./components/countdown-timer.ts"; import { CustomEditor } from "./components/custom-editor.ts"; import { CustomMessageComponent } from "./components/custom-message.ts"; import { DaxnutsComponent } from "./components/daxnuts.ts"; @@ -127,6 +126,14 @@ import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/ import { SessionSelectorComponent } from "./components/session-selector.ts"; import { SettingsSelectorComponent } from "./components/settings-selector.ts"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; +import { + BranchSummaryStatusIndicator, + CompactionStatusIndicator, + IdleStatus, + RetryStatusIndicator, + type StatusIndicator, + WorkingStatusIndicator, +} from "./components/status-indicator.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; @@ -362,12 +369,12 @@ export class InteractiveMode { private isInitialized = false; private onInputCallback?: (text: string) => void; private pendingUserInputs: string[] = []; - private loadingAnimation: Loader | undefined = undefined; + private activeStatusIndicator: StatusIndicator | undefined = undefined; + private readonly idleStatus = new IdleStatus(); private workingMessage: string | undefined = undefined; private workingVisible = true; - private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; + private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined; private workingStartedAt: number | undefined = undefined; - private workingElapsedIntervalId: NodeJS.Timeout | undefined = undefined; private readonly defaultWorkingMessage = "Working"; private readonly defaultHiddenThinkingLabel = "Thinking..."; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; @@ -404,6 +411,7 @@ export class InteractiveMode { // Thinking block visibility state private hideThinkingBlock = false; + private outputPad = 1; // Skill commands: command name -> skill file path private skillCommands = new Map(); @@ -422,13 +430,10 @@ export class InteractiveMode { private pendingBashComponents: BashExecutionComponent[] = []; // Auto-compaction state - private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void; private autoCompactionProgressText = ""; // Auto-retry state - private retryLoader: Loader | undefined = undefined; - private retryCountdown: CountdownTimer | undefined = undefined; private retryEscapeHandler?: () => void; // Messages queued while compaction is running @@ -513,6 +518,7 @@ export class InteractiveMode { // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); @@ -1652,7 +1658,7 @@ export class InteractiveMode { commandContextActions: { waitForIdle: () => this.session.agent.waitForIdle(), newSession: async (options) => { - this.stopWorkingLoader(); + this.clearStatusIndicator(); try { return await this.runtimeHost.newSession(options); } catch (error: unknown) { @@ -1724,8 +1730,13 @@ export class InteractiveMode { this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); - this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); + const clearOnShrink = this.settingsManager.getClearOnShrink(); + this.ui.setClearOnShrink(clearOnShrink); + if (!clearOnShrink && !this.activeStatusIndicator) { + this.statusContainer.clear(); + } const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor.setPaddingX(editorPaddingX); @@ -1847,36 +1858,6 @@ export class InteractiveMode { this.ui.requestRender(); } - private getWorkingLoaderMessage(): string { - return this.workingMessage ?? this.defaultWorkingMessage; - } - - private getWorkingElapsedSeconds(): number { - if (this.workingStartedAt === undefined) { - return 0; - } - return Math.max(0, Math.floor((Date.now() - this.workingStartedAt) / 1000)); - } - - private refreshWorkingLoaderMessage(): void { - this.loadingAnimation?.setMessage(this.getWorkingLoaderMessage()); - } - - private startWorkingElapsedTimer(): void { - this.stopWorkingElapsedTimer(); - this.workingStartedAt = Date.now(); - this.workingElapsedIntervalId = setInterval(() => { - this.refreshWorkingLoaderMessage(); - }, DEFAULT_WORKING_STATUS_REFRESH_INTERVAL_MS); - } - - private stopWorkingElapsedTimer(): void { - if (this.workingElapsedIntervalId) { - clearInterval(this.workingElapsedIntervalId); - this.workingElapsedIntervalId = undefined; - } - } - private startToolHookStatusTimer(): void { if (this.hookStatusIntervalId) { return; @@ -2005,6 +1986,26 @@ export class InteractiveMode { this.stopToolHookStatusTimer(); } + private getWorkingElapsedSeconds(): number { + if (this.workingStartedAt === undefined) { + return 0; + } + return Math.max(0, Math.floor((Date.now() - this.workingStartedAt) / 1000)); + } + + private getWorkingLoaderMessage(): string { + return this.workingMessage ?? this.defaultWorkingMessage; + } + + private refreshWorkingLoaderMessage(): void { + if (this.activeStatusIndicator?.kind === "working") { + this.activeStatusIndicator.setMessage(this.getWorkingLoaderMessage()); + return; + } + const legacyLoader = (this as { loadingAnimation?: { setMessage(message: string): void } }).loadingAnimation; + legacyLoader?.setMessage(this.getWorkingLoaderMessage()); + } + private getWorkingIndicatorOptions(): LoaderIndicatorOptions { return ( this.workingIndicatorOptions ?? { @@ -2033,45 +2034,63 @@ export class InteractiveMode { ); } - private createWorkingLoader(): Loader { - return new Loader( - this.ui, - (spinner) => theme.fg("accent", spinner), - (text) => theme.fg("muted", text), - this.getWorkingLoaderMessage(), - this.getWorkingIndicatorOptions(), - ); + private showStatusIndicator(indicator: StatusIndicator): void { + if (indicator.kind === "working") { + this.workingStartedAt = Date.now(); + } + this.activeStatusIndicator?.dispose(); + this.activeStatusIndicator = indicator; + this.statusContainer.clear(); + this.statusContainer.addChild(indicator); } - private stopWorkingLoader(): void { - this.stopWorkingElapsedTimer(); - this.workingStartedAt = undefined; - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; + private updateWorkingIndicatorMessage(): void { + if (this.activeStatusIndicator?.kind === "working") { + this.activeStatusIndicator.setMessage(this.workingMessage ?? this.defaultWorkingMessage); + } + } + + private clearStatusIndicator(kind?: StatusIndicator["kind"]): void { + if (kind && this.activeStatusIndicator?.kind !== kind) { + return; + } + const hadActiveStatusIndicator = this.activeStatusIndicator !== undefined; + const isClearingWorking = this.activeStatusIndicator?.kind === "working"; + this.activeStatusIndicator?.dispose(); + this.activeStatusIndicator = undefined; + if (isClearingWorking) { + this.workingStartedAt = undefined; } this.statusContainer.clear(); + if (hadActiveStatusIndicator && this.ui.getClearOnShrink()) { + this.statusContainer.addChild(this.idleStatus); + } } private setWorkingVisible(visible: boolean): void { this.workingVisible = visible; if (!visible) { - this.stopWorkingLoader(); + this.clearStatusIndicator("working"); this.ui.requestRender(); return; } - if (this.session.isStreaming && !this.loadingAnimation) { - this.statusContainer.clear(); - this.startWorkingElapsedTimer(); - this.loadingAnimation = this.createWorkingLoader(); - this.statusContainer.addChild(this.loadingAnimation); + if (this.session.isStreaming && this.activeStatusIndicator?.kind !== "working") { + this.showStatusIndicator( + new WorkingStatusIndicator( + this.ui, + this.workingMessage ?? this.defaultWorkingMessage, + this.getWorkingIndicatorOptions(), + ), + ); } this.ui.requestRender(); } - private setWorkingIndicator(options?: LoaderIndicatorOptions): void { + private setWorkingIndicator(options?: WorkingIndicatorOptions): void { this.workingIndicatorOptions = options; - this.loadingAnimation?.setIndicator(this.getWorkingIndicatorOptions()); + if (this.activeStatusIndicator?.kind === "working") { + this.activeStatusIndicator.setIndicator(this.getWorkingIndicatorOptions()); + } this.ui.requestRender(); } @@ -2174,7 +2193,7 @@ export class InteractiveMode { this.workingMessage = undefined; this.workingVisible = true; this.setWorkingIndicator(); - this.refreshWorkingLoaderMessage(); + this.updateWorkingIndicatorMessage(); this.setHiddenThinkingLabel(); this.clearToolHookStatuses(); } @@ -2338,7 +2357,7 @@ export class InteractiveMode { setStatus: (key, text) => this.setExtensionStatus(key, text), setWorkingMessage: (message) => { this.workingMessage = message; - this.refreshWorkingLoaderMessage(); + this.updateWorkingIndicatorMessage(); }, setWorkingVisible: (visible) => this.setWorkingVisible(visible), setWorkingIndicator: (options) => this.setWorkingIndicator(options), @@ -3078,19 +3097,16 @@ export class InteractiveMode { this.defaultEditor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } - if (this.retryCountdown) { - this.retryCountdown.dispose(); - this.retryCountdown = undefined; - } - if (this.retryLoader) { - this.retryLoader.stop(); - this.retryLoader = undefined; - } - this.stopWorkingLoader(); if (this.workingVisible) { - this.startWorkingElapsedTimer(); - this.loadingAnimation = this.createWorkingLoader(); - this.statusContainer.addChild(this.loadingAnimation); + this.showStatusIndicator( + new WorkingStatusIndicator( + this.ui, + this.workingMessage ?? this.defaultWorkingMessage, + this.getWorkingIndicatorOptions(), + ), + ); + } else { + this.clearStatusIndicator(); } this.ui.requestRender(); break; @@ -3131,6 +3147,7 @@ export class InteractiveMode { this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, + this.outputPad, ); this.streamingComponent.setExpanded(this.toolOutputExpanded); this.streamingMessage = event.message; @@ -3263,7 +3280,7 @@ export class InteractiveMode { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } - this.stopWorkingLoader(); + this.clearStatusIndicator("working"); this.clearActiveToolExecutionStatus(); this.clearToolHookStatuses(); if (this.streamingComponent) { @@ -3287,37 +3304,25 @@ export class InteractiveMode { this.defaultEditor.onEscape = () => { this.session.abortCompaction(); }; + const indicator = new CompactionStatusIndicator(this.ui, event.reason); + this.activeStatusIndicator?.dispose(); + this.activeStatusIndicator = indicator; this.statusContainer.clear(); - const cancelHint = `(${keyText("app.interrupt")} to cancel)`; - const label = - event.reason === "threshold" - ? `Auto-compacting... ${cancelHint}` - : event.reason === "overflow" - ? `Context overflow detected, compacting... ${cancelHint}` - : event.reason === "pre_prompt" - ? `Compacting before next prompt... ${cancelHint}` - : `Compacting context... ${cancelHint}`; - this.autoCompactionLoader = new Loader( - this.ui, - (spinner) => theme.fg("accent", spinner), - (text) => theme.fg("muted", text), - label, - ); - this.statusContainer.addChild(this.autoCompactionLoader); + this.statusContainer.addChild(indicator); this.autoCompactionProgressText = ""; this.ui.requestRender(); break; } case "compaction_progress": { - if (!this.autoCompactionLoader) break; + if (this.activeStatusIndicator?.kind !== "compaction") break; const nextText = event.text !== undefined ? event.text : `${this.autoCompactionProgressText}${event.delta ?? ""}`; if (!nextText) break; this.autoCompactionProgressText = nextText; const preview = nextText.length > 4_000 ? `...${nextText.slice(nextText.length - 4_000)}` : nextText; this.statusContainer.clear(); - this.statusContainer.addChild(this.autoCompactionLoader); + this.statusContainer.addChild(this.activeStatusIndicator); this.statusContainer.addChild(new Spacer(1)); this.statusContainer.addChild(new Text(theme.fg("muted", preview), 1, 0)); this.ui.requestRender(); @@ -3332,12 +3337,8 @@ export class InteractiveMode { this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.autoCompactionEscapeHandler = undefined; } - if (this.autoCompactionLoader) { - this.autoCompactionLoader.stop(); - this.autoCompactionLoader = undefined; - this.autoCompactionProgressText = ""; - this.statusContainer.clear(); - } + this.clearStatusIndicator("compaction"); + this.autoCompactionProgressText = ""; if (event.aborted) { if (event.reason === "manual") { this.showError("Compaction cancelled"); @@ -3375,28 +3376,9 @@ export class InteractiveMode { // no separate retry-only handler is installed (the prior one only called // session.abortRetry() and left queued steering messages stranded). this.retryEscapeHandler = undefined; - // Show retry indicator - this.statusContainer.clear(); - this.retryCountdown?.dispose(); - const retryMessage = (seconds: number) => - `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`; - this.retryLoader = new Loader( - this.ui, - (spinner) => theme.fg("warning", spinner), - (text) => theme.fg("muted", text), - retryMessage(Math.ceil(event.delayMs / 1000)), + this.showStatusIndicator( + new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs), ); - this.retryCountdown = new CountdownTimer( - event.delayMs, - this.ui, - (seconds) => { - this.retryLoader?.setMessage(retryMessage(seconds)); - }, - () => { - this.retryCountdown = undefined; - }, - ); - this.statusContainer.addChild(this.retryLoader); this.ui.requestRender(); break; } @@ -3407,16 +3389,7 @@ export class InteractiveMode { this.defaultEditor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } - if (this.retryCountdown) { - this.retryCountdown.dispose(); - this.retryCountdown = undefined; - } - // Stop loader - if (this.retryLoader) { - this.retryLoader.stop(); - this.retryLoader = undefined; - this.statusContainer.clear(); - } + this.clearStatusIndicator("retry"); // Show error only on final failure (success shows normal response) if (!event.success) { this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); @@ -3523,11 +3496,16 @@ export class InteractiveMode { const userComponent = new UserMessageComponent( skillBlock.userMessage, this.getMarkdownThemeWithSettings(), + this.outputPad, ); this.chatContainer.addChild(userComponent); } } else { - const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); + const userComponent = new UserMessageComponent( + textContent, + this.getMarkdownThemeWithSettings(), + this.outputPad, + ); this.chatContainer.addChild(userComponent); } if (options?.populateHistory) { @@ -3542,6 +3520,7 @@ export class InteractiveMode { this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, + this.outputPad, ); assistantComponent.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(assistantComponent); @@ -4380,6 +4359,7 @@ export class InteractiveMode { showHardwareCursor: this.settingsManager.getShowHardwareCursor(), defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), editorPaddingX: this.settingsManager.getEditorPaddingX(), + outputPad: this.settingsManager.getOutputPad(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), @@ -4482,6 +4462,23 @@ export class InteractiveMode { this.editor.setPaddingX(padding); } }, + onOutputPadChange: (padding) => { + this.settingsManager.setOutputPad(padding); + this.outputPad = padding; + if (this.streamingComponent || this.session.isStreaming) { + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent || child instanceof UserMessageComponent) { + child.setOutputPad(padding); + } + } + if (this.streamingComponent) { + this.streamingComponent.setOutputPad(padding); + } + this.ui.requestRender(); + return; + } + this.rebuildChatFromMessages(); + }, onAutocompleteMaxVisibleChange: (maxVisible) => { this.settingsManager.setAutocompleteMaxVisible(maxVisible); this.defaultEditor.setAutocompleteMaxVisible(maxVisible); @@ -4492,6 +4489,9 @@ export class InteractiveMode { onClearOnShrinkChange: (enabled) => { this.settingsManager.setClearOnShrink(enabled); this.ui.setClearOnShrink(enabled); + if (!enabled && !this.activeStatusIndicator) { + this.statusContainer.clear(); + } }, onShowTerminalProgressChange: (enabled) => { this.settingsManager.setShowTerminalProgress(enabled); @@ -4895,8 +4895,8 @@ export class InteractiveMode { } } - // Set up escape handler and loader if summarizing - let summaryLoader: Loader | undefined; + // Set up escape handler and status indicator if summarizing + let showingSummaryIndicator = false; const originalOnEscape = this.defaultEditor.onEscape; if (wantsSummary) { @@ -4904,13 +4904,8 @@ export class InteractiveMode { this.session.abortBranchSummary(); }; this.chatContainer.addChild(new Spacer(1)); - summaryLoader = new Loader( - this.ui, - (spinner) => theme.fg("accent", spinner), - (text) => theme.fg("muted", text), - `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, - ); - this.statusContainer.addChild(summaryLoader); + this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui)); + showingSummaryIndicator = true; this.ui.requestRender(); } @@ -4942,9 +4937,8 @@ export class InteractiveMode { } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } finally { - if (summaryLoader) { - summaryLoader.stop(); - this.statusContainer.clear(); + if (showingSummaryIndicator) { + this.clearStatusIndicator("branchSummary"); } this.defaultEditor.onEscape = originalOnEscape; } @@ -5006,7 +5000,7 @@ export class InteractiveMode { sessionPath: string, options?: Parameters[1], ): Promise<{ cancelled: boolean }> { - this.stopWorkingLoader(); + this.clearStatusIndicator(); try { const result = await this.runtimeHost.switchSession(sessionPath, { withSession: options?.withSession, @@ -5495,6 +5489,7 @@ export class InteractiveMode { return; } this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); this.rebuildChatFromMessages(); chatRestoredBeforeSessionStart = true; }; @@ -5519,7 +5514,11 @@ export class InteractiveMode { this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); } this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); - this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); + const clearOnShrink = this.settingsManager.getClearOnShrink(); + this.ui.setClearOnShrink(clearOnShrink); + if (!clearOnShrink && !this.activeStatusIndicator) { + this.statusContainer.clear(); + } this.setupAutocompleteProvider(); const runner = this.session.extensionRunner; this.setupExtensionShortcuts(runner); @@ -5606,7 +5605,7 @@ export class InteractiveMode { } try { - this.stopWorkingLoader(); + this.clearStatusIndicator(); const result = await this.runtimeHost.importFromJsonl(inputPath); if (result.cancelled) { this.showStatus("Import cancelled"); @@ -5932,7 +5931,7 @@ export class InteractiveMode { } private async handleClearCommand(): Promise { - this.stopWorkingLoader(); + this.clearStatusIndicator(); try { const result = await this.runtimeHost.newSession(); if (result.cancelled) { @@ -6099,8 +6098,7 @@ export class InteractiveMode { return; } - this.stopWorkingLoader(); - this.statusContainer.clear(); + this.clearStatusIndicator(); try { await this.session.compact(customInstructions); @@ -6113,7 +6111,7 @@ export class InteractiveMode { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } - this.stopWorkingLoader(); + this.clearStatusIndicator(); this.clearPendingTools(); this.clearActiveToolExecutionStatus(); this.clearToolHookStatuses(); diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 4c46feff3..eca52af74 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -10,6 +10,7 @@ import type { ImageContent } from "@earendil-works/pi-ai"; import type { SessionStats } from "../../core/agent-session.ts"; import type { BashResult } from "../../core/bash-executor.ts"; import type { CompactionResult } from "../../core/compaction/index.ts"; +import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; import type { RpcCommand, RpcResponse, RpcSessionState, RpcSlashCommand } from "./rpc-types.ts"; @@ -388,6 +389,22 @@ export class RpcClient { return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; } + /** + * Get session entries in append order, optionally only those after the `since` entry id. + */ + async getEntries(since?: string): Promise<{ entries: SessionEntry[]; leafId: string | null }> { + const response = await this.send({ type: "get_entries", since }); + return this.getData<{ entries: SessionEntry[]; leafId: string | null }>(response); + } + + /** + * Get the session entry tree. + */ + async getTree(): Promise<{ tree: SessionTreeNode[]; leafId: string | null }> { + const response = await this.send({ type: "get_tree" }); + return this.getData<{ tree: SessionTreeNode[]; leafId: string | null }>(response); + } + /** * Get text of last assistant message. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index dbe1cac93..9d762f59b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -617,6 +617,24 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise e.id === command.since); + if (sinceIndex === -1) { + return error(id, "get_entries", `Entry not found: ${command.since}`); + } + entries = entries.slice(sinceIndex + 1); + } + return success(id, "get_entries", { entries, leafId: sessionManager.getLeafId() }); + } + + case "get_tree": { + const sessionManager = session.sessionManager; + return success(id, "get_tree", { tree: sessionManager.getTree(), leafId: sessionManager.getLeafId() }); + } + case "get_last_assistant_text": { const text = session.getLastAssistantText(); return success(id, "get_last_assistant_text", { text }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 9457cf049..7a00b0b6e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -10,6 +10,7 @@ import type { ImageContent, Model } from "@earendil-works/pi-ai"; import type { SessionStats } from "../../core/agent-session.ts"; import type { BashResult } from "../../core/bash-executor.ts"; import type { CompactionResult } from "../../core/compaction/index.ts"; +import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts"; import type { SourceInfo } from "../../core/source-info.ts"; // ============================================================================ @@ -59,6 +60,8 @@ export type RpcCommand = | { id?: string; type: "fork"; entryId: string } | { id?: string; type: "clone" } | { id?: string; type: "get_fork_messages" } + | { id?: string; type: "get_entries"; since?: string } + | { id?: string; type: "get_tree" } | { id?: string; type: "get_last_assistant_text" } | { id?: string; type: "set_session_name"; name: string } @@ -181,6 +184,20 @@ export type RpcResponse = success: true; data: { messages: Array<{ entryId: string; text: string }> }; } + | { + id?: string; + type: "response"; + command: "get_entries"; + success: true; + data: { entries: SessionEntry[]; leafId: string | null }; + } + | { + id?: string; + type: "response"; + command: "get_tree"; + success: true; + data: { tree: SessionTreeNode[]; leafId: string | null }; + } | { id?: string; type: "response"; diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index db79525f1..3210e1278 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -1,7 +1,9 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, test } from "vitest"; import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts"; +import { UserMessageComponent } from "../src/modes/interactive/components/user-message.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; @@ -277,4 +279,40 @@ describe("AssistantMessageComponent", () => { expect(rendered).toContain("maximum output token limit"); expect(rendered).toContain("response may be incomplete"); }); + + test("uses configured output padding for text and thinking", () => { + initTheme("dark"); + + const component = new AssistantMessageComponent( + createAssistantMessage([ + { type: "text", text: "hello" }, + { type: "thinking", thinking: "reasoning" }, + ]), + false, + undefined, + "Thinking...", + 1, + ); + const lines = component.render(80).map((line) => stripAnsi(line)); + + expect(lines.some((line) => line.includes(" hello"))).toBe(true); + expect(lines.some((line) => line.includes(" reasoning"))).toBe(true); + + component.setOutputPad(0); + const updatedLines = component.render(80).map((line) => stripAnsi(line)); + expect(updatedLines.some((line) => line.startsWith("hello"))).toBe(true); + expect(updatedLines.some((line) => line.startsWith("reasoning"))).toBe(true); + }); + + test("uses configured output padding for user messages", () => { + initTheme("dark"); + + const paddedComponent = new UserMessageComponent("hello", undefined, 1); + const paddedLines = paddedComponent.render(40).map((line) => stripAnsi(line)); + expect(paddedLines.some((line) => line.startsWith(" hello"))).toBe(true); + + const unpaddedComponent = new UserMessageComponent("hello", undefined, 0); + const unpaddedLines = unpaddedComponent.render(40).map((line) => stripAnsi(line)); + expect(unpaddedLines.some((line) => line.startsWith("hello"))).toBe(true); + }); }); diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 9e369159a..1f003445f 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -106,6 +106,7 @@ describe("InteractiveMode compaction events", () => { addMessageToChat: vi.fn(), showError: vi.fn(), showStatus: vi.fn(), + clearStatusIndicator: vi.fn(), flushCompactionQueue: vi.fn().mockResolvedValue(undefined), settingsManager: { getShowTerminalProgress: () => false }, ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } }, diff --git a/packages/coding-agent/test/interactive-mode-import-command.test.ts b/packages/coding-agent/test/interactive-mode-import-command.test.ts index 42937fe9d..ac3f29ebc 100644 --- a/packages/coding-agent/test/interactive-mode-import-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-import-command.test.ts @@ -11,8 +11,7 @@ type InteractiveModePrototype = { }; type ImportCommandContext = { - loadingAnimation?: { stop: () => void }; - statusContainer: { clear: () => void }; + clearStatusIndicator: () => void; runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> }; stopWorkingLoader: () => void; showError: (message: string) => void; @@ -82,7 +81,7 @@ describe("InteractiveMode /import parsing", () => { const showError = vi.fn(); const context: ImportCommandContext = { - statusContainer: { clear: vi.fn() }, + clearStatusIndicator: vi.fn(), runtimeHost: { importFromJsonl }, stopWorkingLoader: vi.fn(), showError, @@ -115,7 +114,7 @@ describe("InteractiveMode /import parsing", () => { const showError = vi.fn(); const context: ImportCommandContext = { - statusContainer: { clear: vi.fn() }, + clearStatusIndicator: vi.fn(), runtimeHost: { importFromJsonl }, stopWorkingLoader: vi.fn(), showError, @@ -149,7 +148,7 @@ describe("InteractiveMode /import parsing", () => { }); const context: ImportCommandContext = { - statusContainer: { clear: vi.fn() }, + clearStatusIndicator: vi.fn(), runtimeHost: { importFromJsonl }, stopWorkingLoader: vi.fn(), showError, diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 87447d3bf..faedcb89f 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -283,6 +283,62 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(text).toContain("test123"); }, 90000); + test("should get session entries with since cursor", async () => { + await client.start(); + + await client.promptAndWait("Reply with just 'ok'"); + + const { entries, leafId } = await client.getEntries(); + expect(entries.length).toBeGreaterThanOrEqual(2); // user + assistant + for (const entry of entries) { + expect(entry.id).toBeDefined(); + } + expect(leafId).toBe(entries[entries.length - 1].id); + + // since cursor returns only entries strictly after the given id + const since = await client.getEntries(entries[0].id); + expect(since.entries.map((e) => e.id)).toEqual(entries.slice(1).map((e) => e.id)); + expect(since.leafId).toBe(leafId); + + // unknown since id is an error response + await expect(client.getEntries("nonexistent-id")).rejects.toThrow("Entry not found"); + }, 90000); + + test("should get session tree", async () => { + await client.start(); + + await client.promptAndWait("Reply with just 'ok'"); + + const { entries, leafId } = await client.getEntries(); + const { tree, leafId: treeLeafId } = await client.getTree(); + expect(treeLeafId).toBe(leafId); + + // Single root whose chain matches the entries + expect(tree.length).toBe(1); + const chainIds: string[] = []; + let nodes = tree; + while (nodes.length === 1) { + chainIds.push(nodes[0].entry.id); + nodes = nodes[0].children; + } + expect(nodes.length).toBe(0); + expect(chainIds).toEqual(entries.map((e) => e.id)); + }, 90000); + + test("should retain pre-compaction entries in get_entries", async () => { + await client.start(); + + await client.promptAndWait("Reply with just 'ok'"); + const before = await client.getEntries(); + + await client.compact(); + + const after = await client.getEntries(); + // Append-only: pre-compaction entries are still there, in the same order + expect(after.entries.slice(0, before.entries.length).map((e) => e.id)).toEqual(before.entries.map((e) => e.id)); + expect(after.entries.some((e) => e.type === "compaction")).toBe(true); + }, 120000); + test("should set and get session name", async () => { await client.start(); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 48043bfee..7023289c7 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -410,6 +410,29 @@ describe("SettingsManager", () => { }); }); + describe("outputPad", () => { + it("should default to 1 and persist binary values", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getOutputPad()).toBe(1); + + manager.setOutputPad(0); + await manager.flush(); + + expect(manager.getOutputPad()).toBe(0); + const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(savedSettings.outputPad).toBe(0); + }); + + it("should treat unsupported outputPad values as default padding", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ outputPad: 2 })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getOutputPad()).toBe(1); + }); + }); + describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json"); diff --git a/packages/coding-agent/test/status-indicator.test.ts b/packages/coding-agent/test/status-indicator.test.ts new file mode 100644 index 000000000..72267fb3f --- /dev/null +++ b/packages/coding-agent/test/status-indicator.test.ts @@ -0,0 +1,32 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IdleStatus, RetryStatusIndicator } from "../src/modes/interactive/components/status-indicator.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +describe("status indicators", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps idle status at the same height as status indicators", () => { + const idleStatus = new IdleStatus(); + + const lines = idleStatus.render(20); + expect(lines).toHaveLength(2); + expect(lines).toEqual([" ".repeat(20), " ".repeat(20)]); + }); + + it("disposes retry countdown updates", () => { + initTheme("dark"); + vi.useFakeTimers(); + const requestRender = vi.fn(); + const tui = { requestRender } as unknown as TUI; + const indicator = new RetryStatusIndicator(tui, 1, 3, 1000); + const callsBeforeDispose = requestRender.mock.calls.length; + + indicator.dispose(); + vi.advanceTimersByTime(2000); + + expect(requestRender).toHaveBeenCalledTimes(callsBeforeDispose); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts b/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts index 6265ce966..721c29dfe 100644 --- a/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts +++ b/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts @@ -37,4 +37,25 @@ describe("regression #3686: session name changes emit an event", () => { expect(harness.sessionManager.getSessionName()).toBe("from extension"); expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["from extension"]); }); + + it("emits session_info_changed to extensions", async () => { + let api: ExtensionAPI | undefined; + const events: Array<{ name: string | undefined }> = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + api = pi; + pi.on("session_info_changed", (event) => { + events.push({ name: event.name }); + }); + }, + ], + }); + harnesses.push(harness); + + api?.setSessionName("first"); + harness.session.setSessionName("second"); + + expect(events).toEqual([{ name: "first" }, { name: "second" }]); + }); }); diff --git a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts index 2c8d88e00..b020205d1 100644 --- a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts +++ b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts @@ -97,6 +97,7 @@ type ReloadCommandContext = { settingsManager: { getHttpIdleTimeoutMs: () => number; getHideThinkingBlock: () => boolean; + getOutputPad: () => 0 | 1; getEditorPaddingX: () => number; getAutocompleteMaxVisible: () => number; getShowHardwareCursor: () => boolean; @@ -168,6 +169,7 @@ function createReloadCommandContext(overrides: ReloadCommandContextOverrides = { settingsManager: { getHttpIdleTimeoutMs: () => 0, getHideThinkingBlock: () => false, + getOutputPad: () => 1, getEditorPaddingX: () => 1, getAutocompleteMaxVisible: () => 10, getShowHardwareCursor: () => false, diff --git a/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts b/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts new file mode 100644 index 000000000..7abe4a5e6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts @@ -0,0 +1,136 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import type { ExtensionFactory } from "../../../src/index.ts"; +import { createHarness } from "../harness.ts"; + +describe("extension active tools next-turn refresh", () => { + it("applies pi.setActiveTools before the next provider request in the same run", async () => { + const extensionFactories: ExtensionFactory[] = [ + (pi) => { + pi.registerTool({ + name: "switch_tools", + label: "Switch Tools", + description: "Switch the active extension tool set", + promptSnippet: "Switch to the next extension tool", + parameters: Type.Object({}), + execute: async () => { + pi.setActiveTools(["after_switch"]); + return { + content: [{ type: "text", text: "switched" }], + details: {}, + }; + }, + }); + + pi.registerTool({ + name: "after_switch", + label: "After Switch", + description: "Tool that should be available after switching", + promptSnippet: "Run after the active tool set changes", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "after" }], + details: {}, + }), + }); + }, + ]; + const harness = await createHarness({ + extensionFactories, + }); + + try { + harness.session.setActiveToolsByName(["switch_tools"]); + + const providerToolNames: string[][] = []; + harness.setResponses([ + (context) => { + providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort()); + return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" }); + }, + (context) => { + providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort()); + return fauxAssistantMessage("done"); + }, + ]); + + expect(harness.session.getActiveToolNames()).toEqual(["switch_tools"]); + + await harness.session.prompt("start"); + + expect(harness.session.getActiveToolNames()).toEqual(["after_switch"]); + expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]); + } finally { + harness.cleanup(); + } + }); + + it("preserves before_agent_start system prompt overrides when tools change mid-run", async () => { + const extensionFactories: ExtensionFactory[] = [ + (pi) => { + pi.on("before_agent_start", async (event) => ({ + systemPrompt: `${event.systemPrompt}\n\nkeep this run override`, + })); + + pi.registerTool({ + name: "switch_tools", + label: "Switch Tools", + description: "Switch the active extension tool set", + promptSnippet: "Switch to the next extension tool", + parameters: Type.Object({}), + execute: async () => { + pi.setActiveTools(["after_switch"]); + return { + content: [{ type: "text", text: "switched" }], + details: {}, + }; + }, + }); + + pi.registerTool({ + name: "after_switch", + label: "After Switch", + description: "Tool that should be available after switching", + promptSnippet: "Run after the active tool set changes", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "after" }], + details: {}, + }), + }); + }, + ]; + const harness = await createHarness({ + extensionFactories, + }); + + try { + harness.session.setActiveToolsByName(["switch_tools"]); + + const providerSystemPrompts: string[] = []; + const providerToolNames: string[][] = []; + harness.setResponses([ + (context) => { + providerSystemPrompts.push(context.systemPrompt ?? ""); + providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort()); + return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" }); + }, + (context) => { + providerSystemPrompts.push(context.systemPrompt ?? ""); + providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort()); + return fauxAssistantMessage("done"); + }, + ]); + + await harness.session.prompt("start"); + + expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]); + expect(providerSystemPrompts).toHaveLength(2); + expect(providerSystemPrompts[0]).toContain("keep this run override"); + expect(providerSystemPrompts[1]).toContain("keep this run override"); + } finally { + harness.cleanup(); + } + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts b/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts new file mode 100644 index 000000000..3f53aba01 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts @@ -0,0 +1,75 @@ +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, getUserTexts, type Harness } from "../harness.ts"; + +function createUsage(totalTokens: number) { + return { + input: totalTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +describe("pre-prompt compaction regression", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("compacts length-stop overflow before a new prompt without continuing from an assistant message", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 100 }], + settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "pre-prompt summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + + const now = Date.now(); + const model = harness.getModel(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "previous prompt" }], + timestamp: now - 1000, + }); + const lengthStopAssistant: AssistantMessage = { + ...fauxAssistantMessage("length-stop assistant response", { stopReason: "length", timestamp: now - 500 }), + api: model.api, + provider: model.provider, + model: model.id, + usage: createUsage(100), + }; + harness.sessionManager.appendMessage(lengthStopAssistant); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; + harness.setResponses([fauxAssistantMessage("answered next prompt")]); + const continueSpy = vi.spyOn(harness.session.agent, "continue"); + + await expect(harness.session.prompt("next prompt")).resolves.toBeUndefined(); + + expect(continueSpy).not.toHaveBeenCalled(); + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "overflow", + aborted: false, + willRetry: true, + }); + expect(getUserTexts(harness)).toContain("next prompt"); + expect(harness.faux.state.callCount).toBe(1); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index aa2f94582..6c4c1072b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.80.3] - 2026-06-30 + ### Added ### Fixed