From 62fad94f1ab17e94a3f6f8d00ef3bae5cd5f8f66 Mon Sep 17 00:00:00 2001 From: Stephan Schneider Date: Wed, 17 Jun 2026 09:21:03 +0200 Subject: [PATCH 01/26] fix(ai): surface provider HTTP error body instead of opaque SDK message Add a shared normalizeProviderError helper in packages/ai/src/utils/error-body.ts and route the 8 body-blind / status-only providers through it (amazon-bedrock, azure-openai-responses, google, google-vertex, images/openrouter, openai-codex-responses, openai-completions, openai-responses). Non-schema 4xx/5xx responses from proxies / gateways now show the real reason carried in the response body alongside the HTTP status, instead of "403 status code (no body)" or "Unknown: UnknownError". The helper probes status (statusCode, status, $metadata.httpStatusCode, $response.statusCode) and body (body, parsed error object, $response.body) across the Mistral, OpenAI, Google, and Bedrock SDK shapes, truncates the body at a 4000 char cap, and preserves error.message when the SDK already folded the body in (Anthropic / Google happy path). mistral.ts and anthropic.ts are left untouched. Provider prefixes and the OpenRouter metadata.raw append are preserved. closes #5763 --- packages/ai/src/api/azure-openai-responses.ts | 15 +- .../ai/src/api/bedrock-converse-stream.ts | 16 +- packages/ai/src/api/google-generative-ai.ts | 3 +- packages/ai/src/api/google-vertex.ts | 3 +- packages/ai/src/api/openai-codex-responses.ts | 3 +- packages/ai/src/api/openai-completions.ts | 10 +- packages/ai/src/api/openai-responses.ts | 15 +- packages/ai/src/api/openrouter-images.ts | 3 +- packages/ai/src/utils/error-body.ts | 127 ++++++++++++ packages/ai/test/error-body.test.ts | 154 ++++++++++++++ .../provider-error-body-passthrough.test.ts | 78 ++++++++ .../provider-error-body-regression.test.ts | 189 ++++++++++++++++++ 12 files changed, 580 insertions(+), 36 deletions(-) create mode 100644 packages/ai/src/utils/error-body.ts create mode 100644 packages/ai/test/error-body.test.ts create mode 100644 packages/ai/test/provider-error-body-passthrough.test.ts create mode 100644 packages/ai/test/provider-error-body-regression.test.ts diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 137c43c91..1bf4fbd18 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 68af206de..d0d2b6042 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -47,6 +47,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"; @@ -322,15 +323,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}`; } /** diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 1e90e53ae..f2e6e0ecd 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"; @@ -271,7 +272,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 f4ea7b5c0..9fbfbb5a3 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -24,6 +24,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"; @@ -288,7 +289,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 5db519a7e..102ea969e 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"; @@ -411,7 +412,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 27f44b2cf..e5c587dad 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -32,6 +32,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"; @@ -463,10 +464,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 = (error as any)?.error?.metadata?.raw; - 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(); } diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 40998eaa3..acde1300c 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"; @@ -70,19 +71,7 @@ function getPromptCacheRetention( } 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/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/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/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"); + }); +}); From 73581ea995e5e2abae51fc29cf535f42b7d31403 Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Thu, 25 Jun 2026 21:43:03 +0800 Subject: [PATCH 02/26] fix(coding-agent): avoid pre-prompt compaction continue --- .../coding-agent/src/core/agent-session.ts | 14 +--- .../pre-prompt-compaction-no-continue.test.ts | 75 +++++++++++++++++++ 2 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index fafa1031e..efd21e71c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1075,17 +1075,11 @@ 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))) { - try { - await this.agent.continue(); - while (await this._handlePostAgentRun()) { - await this.agent.continue(); - } - } finally { - this._flushPendingBashMessages(); - } + if (lastAssistant) { + await this._checkCompaction(lastAssistant, false); } // Build messages array (custom message if any, then user message) 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); + }); +}); From 7ba1b6bfeff9aa72dbdbc4d386103e06b2f53803 Mon Sep 17 00:00:00 2001 From: Anton Geraschenko Date: Thu, 11 Jun 2026 15:59:27 -0700 Subject: [PATCH 03/26] feat(coding-agent): add get_entries and get_tree RPC commands Adds two read-only RPC commands exposing existing SessionManager reads: - get_entries: all session entries in append order, with optional since-entry-id cursor (strictly-after semantics, error on unknown id), plus current leafId. - get_tree: getTree() roots plus current leafId. Since sessions are append-only trees with stable entry ids, an entry id is a durable cursor: external orchestrators can use these commands to catch up after a restart without losing pre-compaction history, and can observe branch structure (/tree jumps, abandoned branches) that get_messages hides. --- packages/coding-agent/docs/rpc.md | 58 +++++++++++++++++++ packages/coding-agent/src/index.ts | 1 + .../coding-agent/src/modes/rpc/rpc-client.ts | 17 ++++++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 18 ++++++ .../coding-agent/src/modes/rpc/rpc-types.ts | 17 ++++++ packages/coding-agent/test/rpc.test.ts | 56 ++++++++++++++++++ 6 files changed, 167 insertions(+) diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index a99424095..bf6cf1304 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/src/index.ts b/packages/coding-agent/src/index.ts index 5830ecdad..0be0a8da5 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -224,6 +224,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/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 1150b8264..38cac6415 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -606,6 +606,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 b65f2ab4e..02249ade8 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/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(); From 8f64353e654c4b9a7afa800757086816cd5f1eb4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 28 Jun 2026 18:53:35 +0200 Subject: [PATCH 04/26] fix: restrict bot gate bypasses Refs #6127 --- .github/workflows/issue-gate.yml | 10 ++++++---- .github/workflows/pr-gate.yml | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index d2bf830ec..6fcbfa210 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -17,11 +17,13 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); const issueAuthor = context.payload.issue.user.login; const defaultBranch = context.payload.repository.default_branch; + const isBotAuthor = issueAuthor.endsWith('[bot]'); - if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') { - console.log(`Skipping bot: ${issueAuthor}`); + if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) { + console.log(`Skipping trusted bot: ${issueAuthor}`); return; } @@ -80,7 +82,7 @@ jobs: } const permission = await getPermission(issueAuthor); - if (['admin', 'maintain', 'write'].includes(permission)) { + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { console.log(`${issueAuthor} is a collaborator with ${permission} access`); return; } @@ -89,7 +91,7 @@ jobs: const approvedUsers = parseApprovedUsers(approvedContent); const capability = approvedUsers.get(issueAuthor.toLowerCase()); - if (capability === 'issue' || capability === 'pr') { + if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) { console.log(`${issueAuthor} is approved for ${capability}`); return; } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index a62b4afb5..669fd95b1 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -18,11 +18,13 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); const prAuthor = context.payload.pull_request.user.login; const defaultBranch = context.payload.repository.default_branch; + const isBotAuthor = prAuthor.endsWith('[bot]'); - if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') { - console.log(`Skipping bot: ${prAuthor}`); + if (TRUSTED_BOT_AUTHORS.has(prAuthor)) { + console.log(`Skipping trusted bot: ${prAuthor}`); return; } @@ -97,7 +99,7 @@ jobs: } const permission = await getPermission(prAuthor); - if (['admin', 'maintain', 'write'].includes(permission)) { + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { console.log(`${prAuthor} is a collaborator with ${permission} access`); return; } @@ -106,7 +108,7 @@ jobs: const approvedUsers = parseApprovedUsers(approvedContent); const capability = approvedUsers.get(prAuthor.toLowerCase()); - if (capability === 'pr') { + if (!isBotAuthor && capability === 'pr') { console.log(`${prAuthor} is approved for PRs`); return; } From 54113731b2e70ceb61ea1948fdfe83395ff2fd90 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 28 Jun 2026 19:57:47 +0200 Subject: [PATCH 05/26] fix(ai): use HTTP timeout for Codex SSE headers Refs #4945 --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/api/openai-codex-responses.ts | 33 +++++-------------- packages/ai/test/openai-codex-stream.test.ts | 21 +++--------- 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 597bba9bd..ec294b772 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,10 @@ - Added an optional `reasoning` field to `Usage` reporting reasoning/thinking token counts as a subset of `output`. Populated for Anthropic (`output_tokens_details.thinking_tokens`), OpenAI Responses/Codex/Azure (`output_tokens_details.reasoning_tokens`), OpenAI Completions (`completion_tokens_details.reasoning_tokens`), and Google Generative AI / Vertex (`thoughtsTokenCount`). Bedrock Converse and Mistral are not populated because those APIs do not return a reasoning token breakdown ([#6057](https://github.com/earendil-works/pi/issues/6057)). +### 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 `streamSimple()` to send a context-aware max-token cap so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)). diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 267fa90b1..6846cedf0 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -56,9 +56,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; @@ -179,20 +176,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 // ============================================================================ @@ -245,7 +228,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); @@ -269,7 +252,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons () => { websocketStarted = true; }, - idleTimeoutMs, + httpTimeoutMs, websocketConnectTimeoutMs, options, ); @@ -325,8 +308,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", @@ -335,11 +319,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) }, diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index b68d5b73c..bc0d83ca9 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -311,8 +311,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) => { @@ -357,25 +356,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 () => { From b91bdd5a3e5ad20f11175fd7a02226b9a1f2b5b3 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 29 Jun 2026 08:40:20 +0200 Subject: [PATCH 06/26] fix(ai): preserve Z.AI thinking content closes #6083 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/openai-completions.ts | 4 +- .../openai-completions-tool-choice.test.ts | 62 ++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ec294b772..707f112ad 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed `streamSimple()` to send a context-aware max-token cap so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)). - Fixed OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)). - Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)). +- 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)). ## [0.80.2] - 2026-06-23 diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index caeb8638e..7da118f71 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -593,10 +593,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/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 927e34b31..ce23bdbeb 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -343,11 +343,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; From 541d11f725d597baa92d90a127a5e9f92713b24d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 29 Jun 2026 06:41:07 +0000 Subject: [PATCH 07/26] chore: approve contributor skhoroshavin --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index d1f5bfea7..10a011086 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -245,3 +245,5 @@ dodiego pr any-victor pr geraschenko pr + +skhoroshavin pr From 5d499272a879398985ad9b1695866ab3d8685053 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Jun 2026 16:53:39 +0200 Subject: [PATCH 08/26] fix(coding-agent): stabilize interactive status indicators Closes #6026 --- packages/coding-agent/CHANGELOG.md | 1 + .../components/status-indicator.ts | 114 +++++++++ .../src/modes/interactive/interactive-mode.ts | 228 +++++++----------- .../interactive-mode-import-command.test.ts | 9 +- .../test/status-indicator.test.ts | 32 +++ 5 files changed, 236 insertions(+), 148 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/status-indicator.ts create mode 100644 packages/coding-agent/test/status-indicator.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a4de8b62..e3b5f016d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- 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)). - Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)). - Fixed user-message transcript rendering to keep visible backslashes in Markdown escape sequences such as `\"` ([#6105](https://github.com/earendil-works/pi/issues/6105)). - Fixed assistant messages stopped by output length to show a visible incomplete-response error ([#4290](https://github.com/earendil-works/pi/issues/4290)). 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..cb7f2ef82 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -0,0 +1,114 @@ +import { type Component, Loader, type TUI } from "@earendil-works/pi-tui"; +import type { WorkingIndicatorOptions } from "../../../core/extensions/index.ts"; +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?: WorkingIndicatorOptions, + ) { + super(ui, spinnerColorFn, messageColorFn, message, indicator); + this.kind = kind; + } + + dispose(): void { + this.stop(); + } +} + +export class WorkingStatusIndicator extends StatusIndicator { + constructor(ui: TUI, message: string, indicator?: WorkingIndicatorOptions) { + 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"; + +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, " : ""}Auto-compacting... ${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/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4d7ba9287..3945f3044 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -35,8 +35,6 @@ import { fuzzyFilter, getCapabilities, hyperlink, - Loader, - type LoaderIndicatorOptions, Markdown, matchesKey, ProcessTerminal, @@ -72,6 +70,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"; @@ -103,7 +102,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"; @@ -121,6 +119,14 @@ import { ScopedModelsSelectorComponent } from "./components/scoped-models-select 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"; @@ -284,10 +290,11 @@ 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 readonly defaultWorkingMessage = "Working..."; private readonly defaultHiddenThinkingLabel = "Thinking..."; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; @@ -332,12 +339,9 @@ export class InteractiveMode { private pendingBashComponents: BashExecutionComponent[] = []; // Auto-compaction state - private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void; // Auto-retry state - private retryLoader: Loader | undefined = undefined; - private retryCountdown: CountdownTimer | undefined = undefined; private retryEscapeHandler?: () => void; // Messages queued while compaction is running @@ -1548,11 +1552,7 @@ export class InteractiveMode { commandContextActions: { waitForIdle: () => this.session.agent.waitForIdle(), newSession: async (options) => { - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + this.clearStatusIndicator(); try { return await this.runtimeHost.newSession(options); } catch (error: unknown) { @@ -1625,7 +1625,11 @@ export class InteractiveMode { this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); 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); @@ -1744,46 +1748,50 @@ export class InteractiveMode { this.ui.requestRender(); } - private getWorkingLoaderMessage(): string { - return this.workingMessage ?? this.defaultWorkingMessage; - } - - private createWorkingLoader(): Loader { - return new Loader( - this.ui, - (spinner) => theme.fg("accent", spinner), - (text) => theme.fg("muted", text), - this.getWorkingLoaderMessage(), - this.workingIndicatorOptions, - ); + private showStatusIndicator(indicator: StatusIndicator): void { + this.activeStatusIndicator?.dispose(); + this.activeStatusIndicator = indicator; + this.statusContainer.clear(); + this.statusContainer.addChild(indicator); } - private stopWorkingLoader(): void { - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; + private clearStatusIndicator(kind?: StatusIndicator["kind"]): void { + if (kind && this.activeStatusIndicator?.kind !== kind) { + return; } + const hadActiveStatusIndicator = this.activeStatusIndicator !== undefined; + this.activeStatusIndicator?.dispose(); + this.activeStatusIndicator = 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.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.workingIndicatorOptions, + ), + ); } this.ui.requestRender(); } - private setWorkingIndicator(options?: LoaderIndicatorOptions): void { + private setWorkingIndicator(options?: WorkingIndicatorOptions): void { this.workingIndicatorOptions = options; - this.loadingAnimation?.setIndicator(options); + if (this.activeStatusIndicator?.kind === "working") { + this.activeStatusIndicator.setIndicator(options); + } this.ui.requestRender(); } @@ -1882,8 +1890,10 @@ export class InteractiveMode { this.workingMessage = undefined; this.workingVisible = true; this.setWorkingIndicator(); - if (this.loadingAnimation) { - this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); + if (this.activeStatusIndicator?.kind === "working") { + this.activeStatusIndicator.setMessage( + `${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`, + ); } this.setHiddenThinkingLabel(); } @@ -2047,8 +2057,8 @@ export class InteractiveMode { setStatus: (key, text) => this.setExtensionStatus(key, text), setWorkingMessage: (message) => { this.workingMessage = message; - if (this.loadingAnimation) { - this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage); + if (this.activeStatusIndicator?.kind === "working") { + this.activeStatusIndicator.setMessage(message ?? this.defaultWorkingMessage); } }, setWorkingVisible: (visible) => this.setWorkingVisible(visible), @@ -2749,18 +2759,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.loadingAnimation = this.createWorkingLoader(); - this.statusContainer.addChild(this.loadingAnimation); + this.showStatusIndicator( + new WorkingStatusIndicator( + this.ui, + this.workingMessage ?? this.defaultWorkingMessage, + this.workingIndicatorOptions, + ), + ); + } else { + this.clearStatusIndicator(); } this.ui.requestRender(); break; @@ -2924,11 +2932,7 @@ export class InteractiveMode { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - this.statusContainer.clear(); - } + this.clearStatusIndicator("working"); if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); this.streamingComponent = undefined; @@ -2950,19 +2954,7 @@ export class InteractiveMode { this.defaultEditor.onEscape = () => { this.session.abortCompaction(); }; - this.statusContainer.clear(); - const cancelHint = `(${keyText("app.interrupt")} to cancel)`; - const label = - event.reason === "manual" - ? `Compacting context... ${cancelHint}` - : `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; - this.autoCompactionLoader = new Loader( - this.ui, - (spinner) => theme.fg("accent", spinner), - (text) => theme.fg("muted", text), - label, - ); - this.statusContainer.addChild(this.autoCompactionLoader); + this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); this.ui.requestRender(); break; } @@ -2975,11 +2967,7 @@ export class InteractiveMode { this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.autoCompactionEscapeHandler = undefined; } - if (this.autoCompactionLoader) { - this.autoCompactionLoader.stop(); - this.autoCompactionLoader = undefined; - this.statusContainer.clear(); - } + this.clearStatusIndicator("compaction"); if (event.aborted) { if (event.reason === "manual") { this.showError("Compaction cancelled"); @@ -3016,28 +3004,9 @@ export class InteractiveMode { this.defaultEditor.onEscape = () => { this.session.abortRetry(); }; - // 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; } @@ -3048,16 +3017,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"}`); @@ -4111,6 +4071,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); @@ -4490,8 +4453,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) { @@ -4499,13 +4462,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(); } @@ -4537,9 +4495,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; } @@ -4601,11 +4558,7 @@ export class InteractiveMode { sessionPath: string, options?: Parameters[1], ): Promise<{ cancelled: boolean }> { - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + this.clearStatusIndicator(); try { const result = await this.runtimeHost.switchSession(sessionPath, { withSession: options?.withSession, @@ -5114,7 +5067,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); @@ -5201,11 +5158,7 @@ export class InteractiveMode { } try { - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + this.clearStatusIndicator(); const result = await this.runtimeHost.importFromJsonl(inputPath); if (result.cancelled) { this.showStatus("Import cancelled"); @@ -5556,11 +5509,7 @@ export class InteractiveMode { } private async handleClearCommand(): Promise { - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + this.clearStatusIndicator(); try { const result = await this.runtimeHost.newSession(); if (result.cancelled) { @@ -5719,11 +5668,7 @@ export class InteractiveMode { } private async handleCompactCommand(customInstructions?: string): Promise { - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + this.clearStatusIndicator(); try { await this.session.compact(customInstructions); @@ -5736,10 +5681,7 @@ export class InteractiveMode { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } + this.clearStatusIndicator(); this.themeController.disableAutoSync(); this.clearExtensionTerminalInputListeners(); this.footer.dispose(); 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 c2e5ae1fe..cec7a5def 100644 --- a/packages/coding-agent/test/interactive-mode-import-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-import-command.test.ts @@ -10,8 +10,7 @@ type InteractiveModePrototype = { }; type ImportCommandContext = { - loadingAnimation?: { stop: () => void }; - statusContainer: { clear: () => void }; + clearStatusIndicator: () => void; runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> }; showError: (message: string) => void; showStatus: (message: string) => void; @@ -58,7 +57,7 @@ describe("InteractiveMode /import parsing", () => { const showError = vi.fn(); const context: ImportCommandContext = { - statusContainer: { clear: vi.fn() }, + clearStatusIndicator: vi.fn(), runtimeHost: { importFromJsonl }, showError, showStatus, @@ -90,7 +89,7 @@ describe("InteractiveMode /import parsing", () => { const showError = vi.fn(); const context: ImportCommandContext = { - statusContainer: { clear: vi.fn() }, + clearStatusIndicator: vi.fn(), runtimeHost: { importFromJsonl }, showError, showStatus, @@ -123,7 +122,7 @@ describe("InteractiveMode /import parsing", () => { }); const context: ImportCommandContext = { - statusContainer: { clear: vi.fn() }, + clearStatusIndicator: vi.fn(), runtimeHost: { importFromJsonl }, showError, showStatus, 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); + }); +}); From 927e98068cda276bf9188f4774fb927c89823388 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Jun 2026 17:10:53 +0200 Subject: [PATCH 09/26] fix(coding-agent): fix compaction event regression test --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/test/interactive-mode-compaction.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e3b5f016d..832401ec3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed the compaction event regression test to cover status indicator cleanup and keep CI passing. - 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)). - Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)). - Fixed user-message transcript rendering to keep visible backslashes in Markdown escape sequences such as `\"` ([#6105](https://github.com/earendil-works/pi/issues/6105)). diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 0fc90454e..39bcb511d 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -15,6 +15,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() } }, From 726a9c526c8da1fcc3218dbc5e5cba02665dfbc9 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Tue, 30 Jun 2026 00:38:06 -0500 Subject: [PATCH 10/26] fix(coding-agent): emit session name changes to extensions --- packages/coding-agent/docs/extensions.md | 14 +++++++++++++ .../coding-agent/src/core/agent-session.ts | 4 +++- .../coding-agent/src/core/extensions/index.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 9 ++++++++ packages/coding-agent/src/index.ts | 1 + .../3686-session-name-event.test.ts | 21 +++++++++++++++++++ 6 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a9271ee89..83c367a88 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -322,6 +322,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 @@ -395,6 +398,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/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index efd21e71c..c5304a0f0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2683,7 +2683,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 3ed7a6625..32fdd048a 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -128,6 +128,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 7234d4e4f..737800278 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -551,6 +551,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"; @@ -630,6 +637,7 @@ export interface SessionTreeEvent { export type SessionEvent = | SessionStartEvent + | SessionInfoChangedEvent | SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionBeforeCompactEvent @@ -1133,6 +1141,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/index.ts b/packages/coding-agent/src/index.ts index 0be0a8da5..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, 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" }]); + }); }); From 3d6acb37b93d2ceedfcc170b2d212c34fedbf193 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 30 Jun 2026 08:33:18 +0200 Subject: [PATCH 11/26] fix(ai): regenerate model catalog Includes updated Xiaomi MiMo pricing from models.dev. Closes #6138 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/amazon-bedrock.models.ts | 33 ++++++-- packages/ai/src/providers/anthropic.models.ts | 34 -------- packages/ai/src/providers/fireworks.models.ts | 20 ++++- packages/ai/src/providers/groq.models.ts | 2 +- .../ai/src/providers/huggingface.models.ts | 18 +++++ .../ai/src/providers/minimax-cn.models.ts | 8 +- packages/ai/src/providers/minimax.models.ts | 8 +- packages/ai/src/providers/nvidia.models.ts | 19 +++++ .../ai/src/providers/opencode-go.models.ts | 2 +- .../ai/src/providers/openrouter.models.ts | 79 +++++++------------ packages/ai/src/providers/together.models.ts | 19 +++++ .../src/providers/vercel-ai-gateway.models.ts | 52 ++++++------ .../providers/xiaomi-token-plan-ams.models.ts | 24 +++--- .../providers/xiaomi-token-plan-cn.models.ts | 24 +++--- .../providers/xiaomi-token-plan-sgp.models.ts | 24 +++--- packages/ai/src/providers/xiaomi.models.ts | 30 +++---- 17 files changed, 218 insertions(+), 179 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 707f112ad..dc180c8db 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- 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 `streamSimple()` to send a context-aware max-token cap so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)). - Fixed OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)). - Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)). diff --git a/packages/ai/src/providers/amazon-bedrock.models.ts b/packages/ai/src/providers/amazon-bedrock.models.ts index 37c21dce2..f30ad72e4 100644 --- a/packages/ai/src/providers/amazon-bedrock.models.ts +++ b/packages/ai/src/providers/amazon-bedrock.models.ts @@ -376,10 +376,10 @@ export const AMAZON_BEDROCK_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1, - output: 5, - cacheRead: 0.1, - cacheWrite: 1.25, + input: 1.1, + output: 5.5, + cacheRead: 0.11, + cacheWrite: 1.375, }, contextWindow: 200000, maxTokens: 64000, @@ -393,10 +393,10 @@ export const AMAZON_BEDROCK_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 5.5, + output: 27.5, + cacheRead: 0.55, + cacheWrite: 6.875, }, contextWindow: 200000, maxTokens: 64000, @@ -1623,6 +1623,23 @@ export const AMAZON_BEDROCK_MODELS = { contextWindow: 1040000, maxTokens: 8192, } satisfies Model<"bedrock-converse-stream">, + "xai.grok-4.3": { + id: "xai.grok-4.3", + name: "Grok 4.3", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 2.5, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"bedrock-converse-stream">, "zai.glm-4.7": { id: "zai.glm-4.7", name: "GLM-4.7", diff --git a/packages/ai/src/providers/anthropic.models.ts b/packages/ai/src/providers/anthropic.models.ts index 3db30f742..b95e2873c 100644 --- a/packages/ai/src/providers/anthropic.models.ts +++ b/packages/ai/src/providers/anthropic.models.ts @@ -4,40 +4,6 @@ import type { Model } from "../types.ts"; export const ANTHROPIC_MODELS = { - "claude-3-5-haiku-20241022": { - id: "claude-3-5-haiku-20241022", - name: "Claude Haiku 3.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-3-5-haiku-latest": { - id: "claude-3-5-haiku-latest", - name: "Claude Haiku 3.5 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, "claude-3-5-sonnet-20240620": { id: "claude-3-5-sonnet-20240620", name: "Claude Sonnet 3.5", diff --git a/packages/ai/src/providers/fireworks.models.ts b/packages/ai/src/providers/fireworks.models.ts index cb93d846a..91d1115c1 100644 --- a/packages/ai/src/providers/fireworks.models.ts +++ b/packages/ai/src/providers/fireworks.models.ts @@ -74,7 +74,7 @@ export const FIREWORKS_MODELS = { cacheRead: 0.26, cacheWrite: 0, }, - contextWindow: 1048576, + contextWindow: 1048575, maxTokens: 131072, } satisfies Model<"openai-completions">, "accounts/fireworks/models/gpt-oss-120b": { @@ -221,6 +221,24 @@ export const FIREWORKS_MODELS = { contextWindow: 202800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/glm-5p2-fast": { + id: "accounts/fireworks/routers/glm-5p2-fast", + name: "GLM 5.2 Fast", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 2.1, + output: 6.6, + cacheRead: 0.21, + cacheWrite: 0, + }, + contextWindow: 1048575, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "accounts/fireworks/routers/kimi-k2p6-fast": { id: "accounts/fireworks/routers/kimi-k2p6-fast", name: "Kimi K2.6 Fast", diff --git a/packages/ai/src/providers/groq.models.ts b/packages/ai/src/providers/groq.models.ts index 857048c70..6b0e3da27 100644 --- a/packages/ai/src/providers/groq.models.ts +++ b/packages/ai/src/providers/groq.models.ts @@ -100,7 +100,7 @@ export const GROQ_MODELS = { cost: { input: 0.075, output: 0.3, - cacheRead: 0.037, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, diff --git a/packages/ai/src/providers/huggingface.models.ts b/packages/ai/src/providers/huggingface.models.ts index 695b72d6d..4c4b66284 100644 --- a/packages/ai/src/providers/huggingface.models.ts +++ b/packages/ai/src/providers/huggingface.models.ts @@ -652,6 +652,24 @@ export const HUGGINGFACE_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 0.69, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "stepfun-ai/Step-3.5-Flash": { id: "stepfun-ai/Step-3.5-Flash", name: "Step 3.5 Flash", diff --git a/packages/ai/src/providers/minimax-cn.models.ts b/packages/ai/src/providers/minimax-cn.models.ts index d1f90c217..d6af804d0 100644 --- a/packages/ai/src/providers/minimax-cn.models.ts +++ b/packages/ai/src/providers/minimax-cn.models.ts @@ -47,12 +47,12 @@ export const MINIMAX_CN_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.6, - output: 2.4, - cacheRead: 0.12, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 512000, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, } as const; diff --git a/packages/ai/src/providers/minimax.models.ts b/packages/ai/src/providers/minimax.models.ts index 0ff346c7c..7ea1b0142 100644 --- a/packages/ai/src/providers/minimax.models.ts +++ b/packages/ai/src/providers/minimax.models.ts @@ -47,12 +47,12 @@ export const MINIMAX_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.6, - output: 2.4, - cacheRead: 0.12, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 512000, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, } as const; diff --git a/packages/ai/src/providers/nvidia.models.ts b/packages/ai/src/providers/nvidia.models.ts index d0a0c713b..9884e74ff 100644 --- a/packages/ai/src/providers/nvidia.models.ts +++ b/packages/ai/src/providers/nvidia.models.ts @@ -99,6 +99,25 @@ export const NVIDIA_MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "minimaxai/minimax-m3": { + id: "minimaxai/minimax-m3", + name: "MiniMax-M3", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-large-3-675b-instruct-2512": { id: "mistralai/mistral-large-3-675b-instruct-2512", name: "Mistral Large 3 675B Instruct 2512", diff --git a/packages/ai/src/providers/opencode-go.models.ts b/packages/ai/src/providers/opencode-go.models.ts index 6cf159180..cb51ed53a 100644 --- a/packages/ai/src/providers/opencode-go.models.ts +++ b/packages/ai/src/providers/opencode-go.models.ts @@ -184,7 +184,7 @@ export const OPENCODE_GO_MODELS = { cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 512000, + contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"anthropic-messages">, "qwen3.6-plus": { diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index 716afd5dd..0b8bb914f 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -239,25 +239,6 @@ export const OPENROUTER_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4.6-fast": { - id: "anthropic/claude-opus-4.6-fast", - name: "Anthropic: Claude Opus 4.6 (Fast)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - compat: {"thinkingFormat":"openrouter","cacheControlFormat":"anthropic"}, - reasoning: true, - thinkingLevelMap: {"xhigh":"max"}, - input: ["text", "image"], - cost: { - input: 30, - output: 150, - cacheRead: 3, - cacheWrite: 37.5, - }, - contextWindow: 1000000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, "anthropic/claude-opus-4.7": { id: "anthropic/claude-opus-4.7", name: "Anthropic: Claude Opus 4.7", @@ -706,7 +687,7 @@ export const OPENROUTER_MODELS = { cost: { input: 0.2288, output: 0.3432, - cacheRead: 0, + cacheRead: 0.02288, cacheWrite: 0, }, contextWindow: 131072, @@ -1409,9 +1390,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.15, - output: 0.9, - cacheRead: 0.05, + input: 0.12, + output: 0.48, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 204800, @@ -1427,8 +1408,8 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.24, - output: 0.96, + input: 0.18, + output: 0.72, cacheRead: 0, cacheWrite: 0, }, @@ -1775,7 +1756,7 @@ export const OPENROUTER_MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 32768, + maxTokens: 100352, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2-0905": { id: "moonshotai/kimi-k2-0905", @@ -1793,7 +1774,7 @@ export const OPENROUTER_MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 100352, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2-thinking": { id: "moonshotai/kimi-k2-thinking", @@ -1841,9 +1822,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.66, - output: 3.41, - cacheRead: 0.144, + input: 0.55, + output: 3.2, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 262144, @@ -1949,13 +1930,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09, - output: 0.45, + input: 0.085, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1000000, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-super-120b-a12b:free": { id: "nvidia/nemotron-3-super-120b-a12b:free", @@ -2789,13 +2770,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.039, - output: 0.18, + input: 0.03, + output: 0.15, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-oss-120b:free": { id: "openai/gpt-oss-120b:free", @@ -3905,8 +3886,8 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.2885, - output: 3.17, + input: 0.2596, + output: 2.385, cacheRead: 0, cacheWrite: 0, }, @@ -4103,13 +4084,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09, + input: 0.1, output: 0.3, - cacheRead: 0.02, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 65536, } satisfies Model<"openai-completions">, "stepfun/step-3.7-flash": { id: "stepfun/step-3.7-flash", @@ -4445,9 +4426,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.98, - output: 3.08, - cacheRead: 0.182, + input: 0.975, + output: 4.3, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 202752, @@ -4464,13 +4445,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.95, + input: 0.94, output: 3, cacheRead: 0.18, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 32768, + maxTokens: 4096, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -4608,9 +4589,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.66, - output: 3.41, - cacheRead: 0.144, + input: 0.55, + output: 3.2, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 262144, diff --git a/packages/ai/src/providers/together.models.ts b/packages/ai/src/providers/together.models.ts index 6a2614841..5d1e02380 100644 --- a/packages/ai/src/providers/together.models.ts +++ b/packages/ai/src/providers/together.models.ts @@ -360,4 +360,23 @@ export const TOGETHER_MODELS = { contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, + "zai-org/GLM-5.2": { + id: "zai-org/GLM-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","thinkingFormat":"together","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 164000, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/vercel-ai-gateway.models.ts b/packages/ai/src/providers/vercel-ai-gateway.models.ts index ebd3ad453..55cd5031f 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.models.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.models.ts @@ -836,13 +836,13 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.56, - output: 1.68, - cacheRead: 0.28, + input: 0.6, + output: 1.7, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 8192, + contextWindow: 128000, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, "deepseek/deepseek-v3.1-terminus": { id: "deepseek/deepseek-v3.1-terminus", @@ -1788,13 +1788,13 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.6, - output: 2.5, - cacheRead: 0.15, + input: 0.47, + output: 2, + cacheRead: 0.141, cacheWrite: 0, }, - contextWindow: 262114, - maxTokens: 262114, + contextWindow: 216144, + maxTokens: 216144, } satisfies Model<"anthropic-messages">, "moonshotai/kimi-k2.5": { id: "moonshotai/kimi-k2.5", @@ -2480,13 +2480,13 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.35, - output: 0.75, - cacheRead: 0.25, + input: 0.1, + output: 0.5, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131000, + maxTokens: 131072, } satisfies Model<"anthropic-messages">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", @@ -2915,7 +2915,7 @@ export const VERCEL_AI_GATEWAY_MODELS = { } satisfies Model<"anthropic-messages">, "zai/glm-4.5": { id: "zai/glm-4.5", - name: "GLM-4.5", + name: "GLM 4.5", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", @@ -3024,13 +3024,13 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 2.25, - output: 2.75, - cacheRead: 2.25, + input: 0.6, + output: 2.2, + cacheRead: 0.12, cacheWrite: 0, }, - contextWindow: 131000, - maxTokens: 40000, + contextWindow: 200000, + maxTokens: 120000, } satisfies Model<"anthropic-messages">, "zai/glm-4.7-flash": { id: "zai/glm-4.7-flash", @@ -3075,8 +3075,8 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3.2, + input: 0.95, + output: 3.15, cacheRead: 0.2, cacheWrite: 0, }, @@ -3109,13 +3109,13 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.4, - output: 4.4, + input: 1.3, + output: 4.3, cacheRead: 0.26, cacheWrite: 0, }, - contextWindow: 202800, - maxTokens: 64000, + contextWindow: 202000, + maxTokens: 202000, } satisfies Model<"anthropic-messages">, "zai/glm-5.2": { id: "zai/glm-5.2", diff --git a/packages/ai/src/providers/xiaomi-token-plan-ams.models.ts b/packages/ai/src/providers/xiaomi-token-plan-ams.models.ts index fec904284..fad90ac4a 100644 --- a/packages/ai/src/providers/xiaomi-token-plan-ams.models.ts +++ b/packages/ai/src/providers/xiaomi-token-plan-ams.models.ts @@ -14,9 +14,9 @@ export const XIAOMI_TOKEN_PLAN_AMS_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 262144, @@ -32,9 +32,9 @@ export const XIAOMI_TOKEN_PLAN_AMS_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, @@ -50,9 +50,9 @@ export const XIAOMI_TOKEN_PLAN_AMS_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1048576, @@ -68,9 +68,9 @@ export const XIAOMI_TOKEN_PLAN_AMS_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, diff --git a/packages/ai/src/providers/xiaomi-token-plan-cn.models.ts b/packages/ai/src/providers/xiaomi-token-plan-cn.models.ts index 9932fefa4..a3a357da9 100644 --- a/packages/ai/src/providers/xiaomi-token-plan-cn.models.ts +++ b/packages/ai/src/providers/xiaomi-token-plan-cn.models.ts @@ -14,9 +14,9 @@ export const XIAOMI_TOKEN_PLAN_CN_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 262144, @@ -32,9 +32,9 @@ export const XIAOMI_TOKEN_PLAN_CN_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, @@ -50,9 +50,9 @@ export const XIAOMI_TOKEN_PLAN_CN_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1048576, @@ -68,9 +68,9 @@ export const XIAOMI_TOKEN_PLAN_CN_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, diff --git a/packages/ai/src/providers/xiaomi-token-plan-sgp.models.ts b/packages/ai/src/providers/xiaomi-token-plan-sgp.models.ts index dd2489218..3f2d378cc 100644 --- a/packages/ai/src/providers/xiaomi-token-plan-sgp.models.ts +++ b/packages/ai/src/providers/xiaomi-token-plan-sgp.models.ts @@ -14,9 +14,9 @@ export const XIAOMI_TOKEN_PLAN_SGP_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 262144, @@ -32,9 +32,9 @@ export const XIAOMI_TOKEN_PLAN_SGP_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, @@ -50,9 +50,9 @@ export const XIAOMI_TOKEN_PLAN_SGP_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1048576, @@ -68,9 +68,9 @@ export const XIAOMI_TOKEN_PLAN_SGP_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, diff --git a/packages/ai/src/providers/xiaomi.models.ts b/packages/ai/src/providers/xiaomi.models.ts index 23ec9d553..273a23f7e 100644 --- a/packages/ai/src/providers/xiaomi.models.ts +++ b/packages/ai/src/providers/xiaomi.models.ts @@ -14,9 +14,9 @@ export const XIAOMI_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 262144, @@ -32,9 +32,9 @@ export const XIAOMI_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 262144, @@ -50,9 +50,9 @@ export const XIAOMI_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, @@ -68,9 +68,9 @@ export const XIAOMI_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1048576, @@ -86,9 +86,9 @@ export const XIAOMI_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, From 2117b61c6bff817112ad2f9d2820299f386a5a17 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 11:42:39 +0200 Subject: [PATCH 12/26] fix(coding-agent): handle undici mid-stream client errors closes #6133 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/http-dispatcher.ts | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 832401ec3..2332aace7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- 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 the compaction event regression test to cover status indicator cleanup and keep CI passing. - 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)). - Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)). 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. From 6564d9471702727141e20b305d17679e06373e57 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Tue, 30 Jun 2026 04:44:34 -0500 Subject: [PATCH 13/26] feat(coding-agent): add configurable assistant output padding Closes #6168 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 1 + .../coding-agent/src/core/settings-manager.ts | 11 ++++++++ .../components/assistant-message.ts | 14 +++++++++-- .../components/settings-selector.ts | 17 ++++++++++++- .../src/modes/interactive/interactive-mode.ts | 20 +++++++++++++++ .../test/assistant-message.test.ts | 25 +++++++++++++++++++ .../test/settings-manager.test.ts | 23 +++++++++++++++++ .../5943-session-start-notify.test.ts | 2 ++ 9 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2332aace7..c7563adc0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added an `externalEditor` settings.json override for Ctrl+G external editor commands, with default fallbacks to Notepad on Windows and `nano` elsewhere ([#6122](https://github.com/earendil-works/pi/issues/6122)). +- Added an `outputPad` setting for assistant message and thinking horizontal padding. ### Fixed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3a0660571..e93ea1e4e 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -61,6 +61,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `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 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/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 0778f9388..44b489ab2 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -113,6 +113,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 assistant 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; @@ -1182,6 +1183,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/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index e8781c75f..f849b2bcb 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -14,6 +14,7 @@ export class AssistantMessageComponent extends Container { private hideThinkingBlock: boolean; private markdownTheme: MarkdownTheme; private hiddenThinkingLabel: string; + private outputPad: number; private lastMessage?: AssistantMessage; private hasToolCalls = false; @@ -22,12 +23,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(); @@ -59,6 +62,13 @@ export class AssistantMessageComponent extends Container { } } + setOutputPad(padding: number): void { + this.outputPad = padding; + if (this.lastMessage) { + this.updateContent(this.lastMessage); + } + } + override render(width: number): string[] { const lines = super.render(width); if (this.hasToolCalls || lines.length === 0) { @@ -90,7 +100,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. @@ -109,7 +119,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, }), 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 7cc92614d..e67e335a3 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -71,6 +71,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; @@ -100,6 +101,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; @@ -676,9 +678,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 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)", @@ -782,6 +794,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/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3945f3044..a9f0f8152 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -321,6 +321,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(); @@ -429,6 +430,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); @@ -1624,6 +1626,7 @@ 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()); const clearOnShrink = this.settingsManager.getClearOnShrink(); this.ui.setClearOnShrink(clearOnShrink); @@ -2803,6 +2806,7 @@ export class InteractiveMode { this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, + this.outputPad, ); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); @@ -3143,6 +3147,7 @@ export class InteractiveMode { this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, + this.outputPad, ); this.chatContainer.addChild(assistantComponent); break; @@ -3959,6 +3964,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(), @@ -4061,6 +4067,19 @@ export class InteractiveMode { this.editor.setPaddingX(padding); } }, + onOutputPadChange: (padding) => { + this.settingsManager.setOutputPad(padding); + this.outputPad = padding; + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setOutputPad(padding); + } + } + if (this.streamingComponent) { + this.streamingComponent.setOutputPad(padding); + } + this.ui.requestRender(); + }, onAutocompleteMaxVisibleChange: (maxVisible) => { this.settingsManager.setAutocompleteMaxVisible(maxVisible); this.defaultEditor.setAutocompleteMaxVisible(maxVisible); @@ -5043,6 +5062,7 @@ export class InteractiveMode { return; } this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); this.rebuildChatFromMessages(); chatRestoredBeforeSessionStart = true; }; diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index c2e74daab..5ad1cc632 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -2,6 +2,7 @@ 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 { 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"; @@ -71,4 +72,28 @@ 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); + }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 7e5928749..2642b0d67 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -397,6 +397,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/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, From 9be55bc773bc1dffad307dd7cd130d949b336a0b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 11:55:40 +0200 Subject: [PATCH 14/26] fix(coding-agent): apply output padding to user messages closes #6168 --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/settings.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 2 +- .../components/assistant-message.ts | 8 ++--- .../components/settings-selector.ts | 2 +- .../interactive/components/user-message.ts | 29 ++++++++++++++----- .../src/modes/interactive/interactive-mode.ts | 25 +++++++++++----- .../test/assistant-message.test.ts | 13 +++++++++ 8 files changed, 60 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7563adc0..b69f609d7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Added an `externalEditor` settings.json override for Ctrl+G external editor commands, with default fallbacks to Notepad on Windows and `nano` elsewhere ([#6122](https://github.com/earendil-works/pi/issues/6122)). -- Added an `outputPad` setting for assistant message and thinking horizontal padding. +- Added an `outputPad` setting for user message, assistant message, and thinking horizontal padding ([#6168](https://github.com/earendil-works/pi/issues/6168)). ### Fixed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index e93ea1e4e..d1a49fc71 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -61,7 +61,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `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 assistant messages and thinking (0 or 1) | +| `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/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 44b489ab2..6add0810c 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -113,7 +113,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 assistant output (default: 1) + 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; 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 f849b2bcb..d6b17d37d 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -111,7 +111,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)); @@ -144,7 +144,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, ), ); @@ -155,11 +155,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 e67e335a3..81f7cceab 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -683,7 +683,7 @@ export class SettingsSelectorComponent extends Container { items.splice(editorPaddingIndex + 1, 0, { id: "output-padding", label: "Output padding", - description: "Horizontal padding for assistant messages and thinking", + description: "Horizontal padding for user messages, assistant messages, and thinking", currentValue: String(config.outputPad), values: ["0", "1"], }); 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 a9f0f8152..8fa18546f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3128,11 +3128,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) { @@ -4070,15 +4075,19 @@ export class InteractiveMode { onOutputPadChange: (padding) => { this.settingsManager.setOutputPad(padding); this.outputPad = padding; - for (const child of this.chatContainer.children) { - if (child instanceof AssistantMessageComponent) { - child.setOutputPad(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; } - if (this.streamingComponent) { - this.streamingComponent.setOutputPad(padding); - } - this.ui.requestRender(); + this.rebuildChatFromMessages(); }, onAutocompleteMaxVisibleChange: (maxVisible) => { this.settingsManager.setAutocompleteMaxVisible(maxVisible); diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index 5ad1cc632..2244df999 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -1,6 +1,7 @@ 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"; @@ -96,4 +97,16 @@ describe("AssistantMessageComponent", () => { 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); + }); }); From e547bb9f4180599629c45871c8311a51e1ec4f2f Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 30 Jun 2026 13:56:04 +0200 Subject: [PATCH 15/26] fix(coding-agent): refresh session state before next turn --- packages/agent/src/agent.ts | 7 +- .../coding-agent/src/core/agent-session.ts | 20 ++++++ .../extension-active-tools-next-turn.test.ts | 68 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 54020435e..452b8d764 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, @@ -104,6 +105,7 @@ export interface AgentOptions { beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; prepareNextTurn?: ( + context: PrepareNextTurnContext, signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; steeringMode?: QueueMode; @@ -184,6 +186,7 @@ export class Agent { signal?: AbortSignal, ) => Promise; public prepareNextTurn?: ( + context: PrepareNextTurnContext, signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; private activeRun?: ActiveRun; @@ -433,7 +436,9 @@ export class Agent { toolExecution: this.toolExecution, beforeToolCall: this.beforeToolCall, afterToolCall: this.afterToolCall, - prepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined, + prepareNextTurn: this.prepareNextTurn + ? async (context) => await this.prepareNextTurn?.(context, this.signal) + : undefined, convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c5304a0f0..cd25768f1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -352,6 +352,7 @@ export class AgentSession { // (session persistence, extensions, auto-compaction, retry logic) this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); + this._installAgentNextTurnRefresh(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, @@ -462,6 +463,25 @@ export class AgentSession { }; } + private _installAgentNextTurnRefresh(): void { + const previousPrepareNextTurn = this.agent.prepareNextTurn; + this.agent.prepareNextTurn = async (turn, signal) => { + const previousSnapshot = await previousPrepareNextTurn?.(turn, signal); + const previousContext = previousSnapshot?.context ?? turn.context; + + return { + ...previousSnapshot, + context: { + ...previousContext, + systemPrompt: this.agent.state.systemPrompt, + tools: this.agent.state.tools.slice(), + }, + model: this.agent.state.model, + thinkingLevel: this.agent.state.thinkingLevel, + }; + }; + } + // ========================================================================= // Event Subscription // ========================================================================= diff --git a/packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts b/packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts new file mode 100644 index 000000000..74062b639 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts @@ -0,0 +1,68 @@ +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(); + } + }); +}); From fd6659dd5d32d67feaa7ce2ba5eeb87c5705149c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 14:08:48 +0200 Subject: [PATCH 16/26] fix(coding-agent): preserve run prompt during tool refresh closes #6162 --- packages/agent/CHANGELOG.md | 8 ++ packages/agent/src/agent.ts | 19 ++- packages/agent/test/agent.test.ts | 41 ++++++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 21 ++- ...2-extension-active-tools-next-turn.test.ts | 136 ++++++++++++++++++ .../extension-active-tools-next-turn.test.ts | 68 --------- 7 files changed, 217 insertions(+), 77 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/6162-extension-active-tools-next-turn.test.ts delete mode 100644 packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bec415948..7b1f8e001 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added `prepareNextTurnWithContext` for `Agent` users that need the next-turn loop context. + +### Fixed + +- Fixed `Agent.prepareNextTurn` to keep receiving the run abort signal instead of the next-turn context. + ## [0.80.2] - 2026-06-23 ### Changed diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 452b8d764..ab10d5ac1 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -105,6 +105,9 @@ export interface AgentOptions { beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + prepareNextTurnWithContext?: ( context: PrepareNextTurnContext, signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; @@ -186,6 +189,9 @@ export class Agent { signal?: AbortSignal, ) => Promise; public prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + public prepareNextTurnWithContext?: ( context: PrepareNextTurnContext, signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; @@ -212,6 +218,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; @@ -436,9 +443,15 @@ export class Agent { toolExecution: this.toolExecution, beforeToolCall: this.beforeToolCall, afterToolCall: this.afterToolCall, - prepareNextTurn: this.prepareNextTurn - ? async (context) => await this.prepareNextTurn?.(context, 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 5fa27c5aa..f677ee788 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -630,6 +630,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/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b69f609d7..5ea14c7a4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- 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 the compaction event regression test to cover status indicator cleanup and keep CI passing. - 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)). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cd25768f1..ff99d6949 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -21,6 +21,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"; @@ -331,6 +332,7 @@ export class AgentSession { // Base system prompt (without extension appends) - used to apply fresh appends each turn private _baseSystemPrompt = ""; private _baseSystemPromptOptions!: BuildSystemPromptOptions; + private _systemPromptOverride?: string; constructor(config: AgentSessionConfig) { this.agent = config.agent; @@ -464,16 +466,20 @@ export class AgentSession { } private _installAgentNextTurnRefresh(): void { - const previousPrepareNextTurn = this.agent.prepareNextTurn; - this.agent.prepareNextTurn = async (turn, signal) => { - const previousSnapshot = await previousPrepareNextTurn?.(turn, signal); + 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.agent.state.systemPrompt, + systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt, tools: this.agent.state.tools.slice(), }, model: this.agent.state.model, @@ -844,7 +850,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 */ @@ -972,6 +978,7 @@ export class AgentSession { await this.agent.continue(); } } finally { + this._systemPromptOverride = undefined; this._flushPendingBashMessages(); } } @@ -1143,10 +1150,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) { 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/extension-active-tools-next-turn.test.ts b/packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts deleted file mode 100644 index 74062b639..000000000 --- a/packages/coding-agent/test/suite/regressions/extension-active-tools-next-turn.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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(); - } - }); -}); From 5c1a2977bc5f22e497e4db4e67164bdc834d85d8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 22:12:48 +0200 Subject: [PATCH 17/26] fix(ai): update generated model catalogue --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 2 + .../ai/src/api/bedrock-converse-stream.ts | 1 + packages/ai/src/image-models.generated.ts | 60 +++-------- .../ai/src/providers/amazon-bedrock.models.ts | 102 ++++++++++++++++++ packages/ai/src/providers/anthropic.models.ts | 18 ++++ .../ai/src/providers/opencode-go.models.ts | 6 +- .../ai/src/providers/openrouter.models.ts | 66 ++++++------ .../src/providers/vercel-ai-gateway.models.ts | 18 ++++ ...anthropic-adaptive-thinking-models.test.ts | 6 +- .../test/anthropic-thinking-disable.test.ts | 7 ++ .../ai/test/bedrock-thinking-payload.test.ts | 10 ++ 12 files changed, 215 insertions(+), 82 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dc180c8db..ce1fab74c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ ### 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 `streamSimple()` to send a context-aware max-token cap so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)). - Fixed OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3b0677b6a..83a5441e1 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/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index fe01f8720..997965a30 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -575,6 +575,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/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..55a544bb7 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,13 +4445,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.94, + input: 0.93, output: 3, cacheRead: 0.18, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 4096, + maxTokens: 32768, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -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/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 9ecfeb4f4..3f55107c8 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -145,6 +145,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 d2de49131..ad58a4d9e 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -93,6 +93,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"); From f98a154d87bb8e04ea22063780462beec7ecd273 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 22:24:42 +0200 Subject: [PATCH 18/26] docs: audit changelog entries --- packages/ai/CHANGELOG.md | 3 +++ packages/coding-agent/CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ce1fab74c..9d03a468a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added Anthropic Claude Sonnet 5 model metadata for Anthropic-compatible, Bedrock, OpenRouter, and Vercel AI Gateway providers. +- Added Azure OpenAI Responses support for modern Microsoft Foundry endpoint URLs ([#6004](https://github.com/earendil-works/pi/pull/6004) by [@gukoff](https://github.com/gukoff)). - Added an optional `reasoning` field to `Usage` reporting reasoning/thinking token counts as a subset of `output`. Populated for Anthropic (`output_tokens_details.thinking_tokens`), OpenAI Responses/Codex/Azure (`output_tokens_details.reasoning_tokens`), OpenAI Completions (`completion_tokens_details.reasoning_tokens`), and Google Generative AI / Vertex (`thoughtsTokenCount`). Bedrock Converse and Mistral are not populated because those APIs do not return a reasoning token breakdown ([#6057](https://github.com/earendil-works/pi/issues/6057)). ### Changed @@ -14,6 +16,7 @@ - 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 `streamSimple()` to send a context-aware max-token cap so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)). - Fixed OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)). - Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5ea14c7a4..1c43be30e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,42 @@ ## [Unreleased] +### New Features + +- **Anthropic Claude Sonnet 5 support** - Claude Sonnet 5 is available through inherited Anthropic-compatible and Bedrock provider catalogs with adaptive thinking enabled. See [Providers](docs/providers.md) and [Model Options](docs/usage.md#model-options). +- **Configurable output spacing** - `outputPad` controls horizontal padding for user messages, assistant messages, and thinking blocks. See [Settings](docs/settings.md#ui--display). +- **External editor configuration** - `externalEditor` lets Ctrl+G use a configured editor before `$VISUAL`/`$EDITOR` fallbacks. See [Settings](docs/settings.md#ui--display) and [Keybindings](docs/keybindings.md). +- **Richer RPC session tree access** - RPC clients can inspect session entries and tree snapshots with `get_entries` and `get_tree`. See [get_entries](docs/rpc.md#get_entries) and [get_tree](docs/rpc.md#get_tree). +- **Extension session metadata updates** - Extensions can observe session name changes through `session_info_changed`. See [session_info_changed](docs/extensions.md#session_info_changed). +- **Modern Azure Foundry endpoint support** - Azure OpenAI Responses provider setup supports current Microsoft Foundry endpoint URLs. See [Azure OpenAI](docs/providers.md#azure-openai). + ### 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 a package `./rpc-entry` export for launching Pi directly in RPC mode. +- Added session-name change events for extensions ([#6175](https://github.com/earendil-works/pi/pull/6175) by [@xl0](https://github.com/xl0)). +- Added inherited Azure OpenAI Responses support for modern Microsoft Foundry endpoint URLs ([#6004](https://github.com/earendil-works/pi/pull/6004) by [@gukoff](https://github.com/gukoff)). +- Added inherited `Usage.reasoning` token counts for providers that report reasoning/thinking token usage ([#6057](https://github.com/earendil-works/pi/issues/6057)). - Added an `externalEditor` settings.json override for Ctrl+G external editor commands, with default fallbacks to Notepad on Windows and `nano` elsewhere ([#6122](https://github.com/earendil-works/pi/issues/6122)). - Added an `outputPad` setting for user message, assistant message, and thinking horizontal padding ([#6168](https://github.com/earendil-works/pi/issues/6168)). +### Changed + +- Changed the default OpenAI model to `gpt-5.5`. +- 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 `streamSimple()` max-token caps so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)). +- Fixed inherited OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)). +- 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 resource notifications to stay before messages when resuming sessions ([#6048](https://github.com/earendil-works/pi/pull/6048) by [@haoqixu](https://github.com/haoqixu)). +- Fixed startup benchmark timing output to print after TUI shutdown, preserve extension timings, and drain terminal-query replies before stopping benchmark mode ([#6030](https://github.com/earendil-works/pi/pull/6030) by [@xl0](https://github.com/xl0), [#6063](https://github.com/earendil-works/pi/pull/6063) by [@xl0](https://github.com/xl0)). - 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 the compaction event regression test to cover status indicator cleanup and keep CI passing. From a23abe4a695df8b69b613f73e9fdda2a8af894d4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 22:29:41 +0200 Subject: [PATCH 19/26] Release v0.80.3 --- package-lock.json | 30 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +-- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +-- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +-- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 30 +++++++++---------- .../coding-agent/install-lock/package.json | 4 +-- packages/coding-agent/npm-shrinkwrap.json | 24 +++++++-------- packages/coding-agent/package.json | 8 ++--- packages/orchestrator/CHANGELOG.md | 2 +- packages/orchestrator/package.json | 4 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 23 files changed, 72 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index f37deb576..8521d7dfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5115,10 +5115,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.80.2", + "version": "0.80.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5467,7 +5467,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.80.2", + "version": "0.80.3", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5773,12 +5773,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.2", + "version": "0.80.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.2", - "@earendil-works/pi-ai": "^0.80.2", - "@earendil-works/pi-tui": "^0.80.2", + "@earendil-works/pi-agent-core": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5819,32 +5819,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" }, @@ -6140,10 +6140,10 @@ }, "packages/orchestrator": { "name": "@earendil-works/pi-orchestrator", - "version": "0.80.2", + "version": "0.80.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.2" + "@earendil-works/pi-coding-agent": "^0.80.3" }, "devDependencies": { "shx": "0.4.0" @@ -6154,7 +6154,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.80.2", + "version": "0.80.3", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7b1f8e001..9255dc84f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.3] - 2026-06-30 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index 7190e9096..6a4be67be 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.80.2", + "version": "0.80.3", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9d03a468a..fc3438412 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.3] - 2026-06-30 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 20858b7a2..7faaf9515 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.80.2", + "version": "0.80.3", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1c43be30e..bef7d04e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.3] - 2026-06-30 ### New Features 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 b29a04e9a..6d1e9809e 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/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 360fd5168..2f66a88bc 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.2", + "version": "0.80.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.2", + "version": "0.80.3", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.2" + "@earendil-works/pi-coding-agent": "0.80.3" }, "engines": { "node": ">=22.19.0" @@ -450,11 +450,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -464,8 +464,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -488,13 +488,13 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.2", - "@earendil-works/pi-ai": "^0.80.2", - "@earendil-works/pi-tui": "^0.80.2", + "@earendil-works/pi-agent-core": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -522,8 +522,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index df0b42257..5089916c6 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.2", + "version": "0.80.3", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.2" + "@earendil-works/pi-coding-agent": "0.80.3" }, "overrides": { "rimraf": "6.1.2", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 467adb3b3..6a40f2308 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.2", + "version": "0.80.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.2", + "version": "0.80.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.2", - "@earendil-works/pi-ai": "^0.80.2", - "@earendil-works/pi-tui": "^0.80.2", + "@earendil-works/pi-agent-core": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -512,8 +512,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c75ccb3a7..016973d49 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.2", + "version": "0.80.3", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.2", - "@earendil-works/pi-ai": "^0.80.2", - "@earendil-works/pi-tui": "^0.80.2", + "@earendil-works/pi-agent-core": "^0.80.3", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md index 1a588142c..d566ba352 100644 --- a/packages/orchestrator/CHANGELOG.md +++ b/packages/orchestrator/CHANGELOG.md @@ -1,3 +1,3 @@ # Changelog -## [Unreleased] +## [0.80.3] - 2026-06-30 diff --git a/packages/orchestrator/package.json b/packages/orchestrator/package.json index da71a472a..f03cfd6f0 100644 --- a/packages/orchestrator/package.json +++ b/packages/orchestrator/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-orchestrator", - "version": "0.80.2", + "version": "0.80.3", "description": "experimental orchestrator package for pi", "type": "module", "main": "./dist/index.js", @@ -37,7 +37,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.2" + "@earendil-works/pi-coding-agent": "^0.80.3" }, "devDependencies": { "shx": "0.4.0" diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5c207d8c6..3235da91d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.3] - 2026-06-30 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index e407a72d1..a538b5e46 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.80.2", + "version": "0.80.3", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From dd87c02cbf2681c9301cf809146651483ff16030 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Jun 2026 22:29:44 +0200 Subject: [PATCH 20/26] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/orchestrator/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 5 files changed, 10 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 9255dc84f..76af5a22c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.3] - 2026-06-30 ### Added diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fc3438412..a32cb7ef4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.3] - 2026-06-30 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bef7d04e0..c5e0ca1da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.3] - 2026-06-30 ### New Features diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md index d566ba352..bd86cfe1d 100644 --- a/packages/orchestrator/CHANGELOG.md +++ b/packages/orchestrator/CHANGELOG.md @@ -1,3 +1,5 @@ # Changelog +## [Unreleased] + ## [0.80.3] - 2026-06-30 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3235da91d..a0c628d4c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.3] - 2026-06-30 ### Added From 50561489ed9072a3b7ed33c7d1fcdfae1443b40e Mon Sep 17 00:00:00 2001 From: senpi-merge-bot Date: Tue, 30 Jun 2026 20:54:22 +0000 Subject: [PATCH 21/26] sync: record upstream pin dd87c02 --- .github/upstream.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" } From 2097a6da5f4ac9272e4a32e2c610548060b7a0ab Mon Sep 17 00:00:00 2001 From: senpi-merge-bot Date: Tue, 30 Jun 2026 20:55:48 +0000 Subject: [PATCH 22/26] docs(changelog): audit upstream dd87c02 --- packages/agent/CHANGELOG.md | 4 ++++ packages/ai/CHANGELOG.md | 9 +++++++++ packages/coding-agent/CHANGELOG.md | 16 ++++++++++++++++ 3 files changed, 29 insertions(+) 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/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/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 From bcdff49b96bd6a6308d36571ea06a333dfa16ddc Mon Sep 17 00:00:00 2001 From: senpi-merge-bot Date: Tue, 30 Jun 2026 20:57:00 +0000 Subject: [PATCH 23/26] fix(coding-agent): resolve status indicator merge types --- .../src/modes/interactive/components/status-indicator.ts | 9 ++++----- .../src/modes/interactive/interactive-mode.ts | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts index 8a0551cc3..4423fea69 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -1,5 +1,4 @@ -import { type Component, Loader, type TUI } from "@earendil-works/pi-tui"; -import type { WorkingIndicatorOptions } from "../../../core/extensions/index.ts"; +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"; @@ -15,7 +14,7 @@ export class StatusIndicator extends Loader { spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message: string, - indicator?: WorkingIndicatorOptions, + indicator?: LoaderIndicatorOptions, ) { super(ui, spinnerColorFn, messageColorFn, message, indicator); this.kind = kind; @@ -27,7 +26,7 @@ export class StatusIndicator extends Loader { } export class WorkingStatusIndicator extends StatusIndicator { - constructor(ui: TUI, message: string, indicator?: WorkingIndicatorOptions) { + constructor(ui: TUI, message: string, indicator?: LoaderIndicatorOptions) { super( "working", ui, @@ -71,7 +70,7 @@ export class RetryStatusIndicator extends StatusIndicator { } } -export type CompactionStatusReason = "manual" | "threshold" | "overflow" | "pre_prompt"; +export type CompactionStatusReason = "manual" | "threshold" | "overflow" | "pre_prompt" | "branch" | "extension"; export class CompactionStatusIndicator extends StatusIndicator { constructor(ui: TUI, reason: CompactionStatusReason) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0d262aa5d..5c749807f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -36,6 +36,7 @@ import { fuzzyFilter, getCapabilities, hyperlink, + type LoaderIndicatorOptions, Markdown, matchesKey, ProcessTerminal, @@ -1984,7 +1985,7 @@ export class InteractiveMode { this.stopToolHookStatusTimer(); } - private getWorkingIndicatorOptions(): WorkingIndicatorOptions { + private getWorkingIndicatorOptions(): LoaderIndicatorOptions { return ( this.workingIndicatorOptions ?? { frames: theme.getColorMode() === "truecolor" ? ["•"] : [theme.fg("accent", "•"), theme.fg("muted", "◦")], From 6a9c03d071c16f207c62b1200be2d05a951ce14e Mon Sep 17 00:00:00 2001 From: senpi-merge-bot Date: Tue, 30 Jun 2026 21:04:27 +0000 Subject: [PATCH 24/26] fix(coding-agent): preserve fork status and compaction behavior --- .../coding-agent/src/core/agent-session.ts | 17 ++++---- .../components/assistant-message.ts | 1 + .../components/status-indicator.ts | 4 +- .../src/modes/interactive/interactive-mode.ts | 42 ++++++++++++++++--- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b277693f7..f69bd2896 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -772,9 +772,6 @@ export class AgentSession { this._resolveRetry(); await this._checkCompaction(msg); - if (this.agent.hasQueuedMessages()) { - await this._continueAgentAfterCurrentRun(); - } } if (event.type === "agent_end") { @@ -2332,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); } @@ -2376,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, }); @@ -2399,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 { 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 c80dec240..df9a050f7 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -89,6 +89,7 @@ export class AssistantMessageComponent extends Container { setOutputPad(padding: number): void { this.outputPad = padding; if (this.lastMessage) { + this.lastMessageSignature = undefined; this.updateContent(this.lastMessage); } } diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts index 4423fea69..2cc87d293 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -82,7 +82,9 @@ export class CompactionStatusIndicator extends StatusIndicator { ? `Context overflow detected, compacting... ${cancelHint}` : reason === "pre_prompt" ? `Compacting before next prompt... ${cancelHint}` - : `Auto-compacting... ${cancelHint}`; + : reason === "threshold" + ? `Auto-compacting... ${cancelHint}` + : `Compacting context... ${cancelHint}`; super( "compaction", ui, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5c749807f..93fa2294d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -374,6 +374,7 @@ export class InteractiveMode { private workingMessage: string | undefined = undefined; private workingVisible = true; private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined; + private workingStartedAt: number | undefined = undefined; private readonly defaultWorkingMessage = "Working"; private readonly defaultHiddenThinkingLabel = "Thinking..."; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; @@ -1930,7 +1931,7 @@ export class InteractiveMode { this.activeToolExecutions.set(event.toolCallId, label); this.workingMessage = label; this.activeToolExecutionTerminalTitle = `${APP_TITLE} - ${label}`; - this.updateWorkingIndicatorMessage(); + this.refreshWorkingLoaderMessage(); this.applyTerminalTitle(); } @@ -1948,7 +1949,7 @@ export class InteractiveMode { this.workingMessageBeforeActiveTool = undefined; this.activeToolExecutionTerminalTitle = undefined; } - this.updateWorkingIndicatorMessage(); + this.refreshWorkingLoaderMessage(); this.applyTerminalTitle(); } @@ -1971,7 +1972,7 @@ export class InteractiveMode { this.activeToolExecutionTerminalTitle = undefined; this.workingMessage = this.workingMessageBeforeActiveTool; this.workingMessageBeforeActiveTool = undefined; - this.updateWorkingIndicatorMessage(); + this.refreshWorkingLoaderMessage(); this.applyTerminalTitle(); } @@ -1985,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 ?? { @@ -1997,7 +2018,7 @@ export class InteractiveMode { messageFormatter: (message, animationElapsedMs) => formatWorkingStatusMessageFrame( message, - Math.floor(animationElapsedMs / 1000), + this.getWorkingElapsedSeconds(), keyText("app.interrupt"), animationElapsedMs, { @@ -2014,6 +2035,9 @@ export class InteractiveMode { } private showStatusIndicator(indicator: StatusIndicator): void { + if (indicator.kind === "working") { + this.workingStartedAt = Date.now(); + } this.activeStatusIndicator?.dispose(); this.activeStatusIndicator = indicator; this.statusContainer.clear(); @@ -2031,8 +2055,12 @@ export class InteractiveMode { 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); @@ -3276,7 +3304,11 @@ export class InteractiveMode { this.defaultEditor.onEscape = () => { this.session.abortCompaction(); }; - this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); + const indicator = new CompactionStatusIndicator(this.ui, event.reason); + this.activeStatusIndicator?.dispose(); + this.activeStatusIndicator = indicator; + this.statusContainer.clear(); + this.statusContainer.addChild(indicator); this.autoCompactionProgressText = ""; this.ui.requestRender(); break; From 9dc1fe24df58b74bdd1453f0d496b2cb3cc4c01e Mon Sep 17 00:00:00 2001 From: senpi-merge-bot Date: Tue, 30 Jun 2026 21:09:47 +0000 Subject: [PATCH 25/26] docs: record upstream merge report --- .github/agent/last-merge-report.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/agent/last-merge-report.md diff --git a/.github/agent/last-merge-report.md b/.github/agent/last-merge-report.md new file mode 100644 index 000000000..e286433f6 --- /dev/null +++ b/.github/agent/last-merge-report.md @@ -0,0 +1,54 @@ +# Upstream Merge Report + +- Result: clean PR-ready branch +- Upstream repo: badlogic/pi-mono +- Upstream tag: v0.80.3 +- Upstream main SHA: dd87c02cbf2681c9301cf809146651483ff16030 +- Merge commit: 9aadf8f4e +- Pin commit: 50561489e +- Changelog audit commit: 2097a6da5 +- Focused fix commits: bcdff49b9, 6a9c03d07 + +## Preserved Fork Behavior + +- Preserved the fork package identity and CalVer package metadata for `@code-yeongyu/senpi`, `@code-yeongyu/senpi-install`, and `@code-yeongyu/senpi-orchestrator`. +- Preserved fork-only contribution gate removals by keeping `.github/APPROVED_CONTRIBUTORS`, `.github/workflows/issue-gate.yml`, and `.github/workflows/pr-gate.yml` deleted. +- Preserved fork dynamic system prompt construction in `packages/coding-agent/src/core/agent-session.ts` while adopting upstream next-turn context refresh. +- Preserved fork TUI working-status behavior: two-frame/bullet working indicator, elapsed working text, active tool working labels, hook status rows, compaction progress text, and abort queue handling. +- Preserved fork pre-prompt compaction barrier behavior while adopting upstream's no-continue regression coverage. + +## Conflicts Resolved + +- `package-lock.json`: restored fork-compatible workspace identities, then regenerated with `npm install --package-lock-only --ignore-scripts`. +- `packages/coding-agent/install-lock/package-lock.json` and `packages/coding-agent/npm-shrinkwrap.json`: regenerated from fork package metadata with repository generator scripts. +- Package metadata conflicts: kept fork names, private flags, CalVer versions, bundled workspace dependencies, and install package identity. +- Changelog conflicts: kept fork CalVer changelog history during merge; added upstream-facing entries under `## [Unreleased]` in the audit commit. +- `packages/ai/src/api/openai-completions.ts`: merged upstream provider error-body formatting with the fork's typed OpenRouter raw metadata helper. +- `packages/ai/src/providers/openrouter.models.ts`: accepted upstream generated model metadata/pricing updates. +- `packages/coding-agent/src/core/agent-session.ts`: adopted `prepareNextTurnWithContext` refresh, preserved dynamic prompt options and system-prompt override handling, and fixed pre-prompt overflow compaction reason reporting. +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: adopted upstream status indicators while preserving fork working/status behavior and test-harness compatibility. + +## Changelog Audit + +- `packages/agent/CHANGELOG.md`: added `prepareNextTurnWithContext` and `prepareNextTurn` abort-signal fix entries. +- `packages/ai/CHANGELOG.md`: added Claude Sonnet 5 metadata, Codex SSE timeout, Xiaomi pricing, provider error-body, and Z.AI thinking replay entries. +- `packages/coding-agent/CHANGELOG.md`: added inherited model/provider entries plus RPC tree access, session-name extension events, output padding, pre-prompt compaction, extension tool refresh, undici client-error, and status-indicator fixes. + +## QA + +- `npm run build`: passed. +- `npm run check`: passed with no formatter changes. +- Targeted regression rerun: `cd packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/agent-session-concurrent.test.ts test/assistant-message.test.ts test/interactive-mode-compaction.test.ts test/interactive-mode-status.test.ts test/suite/regressions/pre-prompt-compaction-no-continue.test.ts` passed. +- `npm test`: passed. +- Built CLI smoke: `node packages/coding-agent/dist/cli.js --version` and `node packages/coding-agent/dist/cli.js --help` passed. +- senpi QA common self-check: `node .agents/skills/senpi-qa/scripts/lib/common.mjs --self-check` passed. +- senpi QA CLI smoke: `node .agents/skills/senpi-qa/scripts/cli-smoke.mjs --self-test` passed. +- senpi QA mock loop self-test: `node .agents/skills/senpi-qa/scripts/mock-loop.mjs --self-test --evidence upstream-agent-mock-loop` passed. +- senpi QA mock loop evidence run: `node .agents/skills/senpi-qa/scripts/mock-loop.mjs --run "Reply with mock evidence." --evidence upstream-agent-mock-loop` passed; evidence at `local-ignore/qa-evidence/20260630-upstream-agent-mock-loop/`. +- senpi QA TUI smoke: `node .agents/skills/senpi-qa/scripts/tui-smoke.mjs --self-test --driver tmux --evidence upstream-agent-tui` passed; evidence at `local-ignore/qa-evidence/20260630-upstream-agent-tui/tui-smoke-tmux.txt`. + +## Secret Safety + +- QA used isolated senpi sandboxes and verified `/home/runner/.senpi/agent/auth.json` was unchanged. +- Evidence paths are under gitignored `local-ignore/qa-evidence/`. +- No raw credentials, tokens, auth headers, cookies, or private secrets were added to tracked files. From 7f20ae751cb05d42f14583fd12906dcc21daabbe Mon Sep 17 00:00:00 2001 From: senpi-merge-bot Date: Tue, 30 Jun 2026 21:10:05 +0000 Subject: [PATCH 26/26] chore: remove upstream agent report --- .github/agent/last-merge-report.md | 54 ------------------------------ 1 file changed, 54 deletions(-) delete mode 100644 .github/agent/last-merge-report.md diff --git a/.github/agent/last-merge-report.md b/.github/agent/last-merge-report.md deleted file mode 100644 index e286433f6..000000000 --- a/.github/agent/last-merge-report.md +++ /dev/null @@ -1,54 +0,0 @@ -# Upstream Merge Report - -- Result: clean PR-ready branch -- Upstream repo: badlogic/pi-mono -- Upstream tag: v0.80.3 -- Upstream main SHA: dd87c02cbf2681c9301cf809146651483ff16030 -- Merge commit: 9aadf8f4e -- Pin commit: 50561489e -- Changelog audit commit: 2097a6da5 -- Focused fix commits: bcdff49b9, 6a9c03d07 - -## Preserved Fork Behavior - -- Preserved the fork package identity and CalVer package metadata for `@code-yeongyu/senpi`, `@code-yeongyu/senpi-install`, and `@code-yeongyu/senpi-orchestrator`. -- Preserved fork-only contribution gate removals by keeping `.github/APPROVED_CONTRIBUTORS`, `.github/workflows/issue-gate.yml`, and `.github/workflows/pr-gate.yml` deleted. -- Preserved fork dynamic system prompt construction in `packages/coding-agent/src/core/agent-session.ts` while adopting upstream next-turn context refresh. -- Preserved fork TUI working-status behavior: two-frame/bullet working indicator, elapsed working text, active tool working labels, hook status rows, compaction progress text, and abort queue handling. -- Preserved fork pre-prompt compaction barrier behavior while adopting upstream's no-continue regression coverage. - -## Conflicts Resolved - -- `package-lock.json`: restored fork-compatible workspace identities, then regenerated with `npm install --package-lock-only --ignore-scripts`. -- `packages/coding-agent/install-lock/package-lock.json` and `packages/coding-agent/npm-shrinkwrap.json`: regenerated from fork package metadata with repository generator scripts. -- Package metadata conflicts: kept fork names, private flags, CalVer versions, bundled workspace dependencies, and install package identity. -- Changelog conflicts: kept fork CalVer changelog history during merge; added upstream-facing entries under `## [Unreleased]` in the audit commit. -- `packages/ai/src/api/openai-completions.ts`: merged upstream provider error-body formatting with the fork's typed OpenRouter raw metadata helper. -- `packages/ai/src/providers/openrouter.models.ts`: accepted upstream generated model metadata/pricing updates. -- `packages/coding-agent/src/core/agent-session.ts`: adopted `prepareNextTurnWithContext` refresh, preserved dynamic prompt options and system-prompt override handling, and fixed pre-prompt overflow compaction reason reporting. -- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: adopted upstream status indicators while preserving fork working/status behavior and test-harness compatibility. - -## Changelog Audit - -- `packages/agent/CHANGELOG.md`: added `prepareNextTurnWithContext` and `prepareNextTurn` abort-signal fix entries. -- `packages/ai/CHANGELOG.md`: added Claude Sonnet 5 metadata, Codex SSE timeout, Xiaomi pricing, provider error-body, and Z.AI thinking replay entries. -- `packages/coding-agent/CHANGELOG.md`: added inherited model/provider entries plus RPC tree access, session-name extension events, output padding, pre-prompt compaction, extension tool refresh, undici client-error, and status-indicator fixes. - -## QA - -- `npm run build`: passed. -- `npm run check`: passed with no formatter changes. -- Targeted regression rerun: `cd packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/agent-session-concurrent.test.ts test/assistant-message.test.ts test/interactive-mode-compaction.test.ts test/interactive-mode-status.test.ts test/suite/regressions/pre-prompt-compaction-no-continue.test.ts` passed. -- `npm test`: passed. -- Built CLI smoke: `node packages/coding-agent/dist/cli.js --version` and `node packages/coding-agent/dist/cli.js --help` passed. -- senpi QA common self-check: `node .agents/skills/senpi-qa/scripts/lib/common.mjs --self-check` passed. -- senpi QA CLI smoke: `node .agents/skills/senpi-qa/scripts/cli-smoke.mjs --self-test` passed. -- senpi QA mock loop self-test: `node .agents/skills/senpi-qa/scripts/mock-loop.mjs --self-test --evidence upstream-agent-mock-loop` passed. -- senpi QA mock loop evidence run: `node .agents/skills/senpi-qa/scripts/mock-loop.mjs --run "Reply with mock evidence." --evidence upstream-agent-mock-loop` passed; evidence at `local-ignore/qa-evidence/20260630-upstream-agent-mock-loop/`. -- senpi QA TUI smoke: `node .agents/skills/senpi-qa/scripts/tui-smoke.mjs --self-test --driver tmux --evidence upstream-agent-tui` passed; evidence at `local-ignore/qa-evidence/20260630-upstream-agent-tui/tui-smoke-tmux.txt`. - -## Secret Safety - -- QA used isolated senpi sandboxes and verified `/home/runner/.senpi/agent/auth.json` was unchanged. -- Evidence paths are under gitignored `local-ignore/qa-evidence/`. -- No raw credentials, tokens, auth headers, cookies, or private secrets were added to tracked files.