From 588639fa97567a661dac876dc2b1970c8a3497ae Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 2 May 2026 20:36:07 +0200 Subject: [PATCH 01/12] feat(read): compact resource read rendering --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/core/tools/read.ts | 120 ++++++++++++++++-- .../test/tool-execution-component.test.ts | 60 ++++++++- 3 files changed, 174 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f63f4aac..5b3dc615 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed `read` tool rendering to collapse Pi documentation, AGENTS/CLAUDE context files, and `SKILL.md` contents by default in interactive output. + ## [0.72.1] - 2026-05-02 ## [0.72.0] - 2026-05-01 diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 8f8e4656..08e4f825 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -1,11 +1,13 @@ +import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; import { Text } from "@mariozechner/pi-tui"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; import { type Static, Type } from "typebox"; -import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; -import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js"; +import { getReadmePath } from "../../config.js"; +import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.js"; +import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.js"; import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; @@ -26,6 +28,27 @@ export interface ReadToolDetails { truncation?: TruncationResult; } +interface CompactReadClassification { + kind: "docs" | "resource" | "skill"; + label: string; +} + +interface ReadRenderState { + hideCall?: boolean; +} + +class ReadCallText extends Text { + constructor(private state: ReadRenderState) { + super("", 0, 0); + } + + override render(width: number): string[] { + return this.state.hideCall ? [] : super.render(width); + } +} + +const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); + /** * Pluggable operations for the read tool. * Override these to delegate file reading to remote systems (for example SSH). @@ -54,7 +77,7 @@ export interface ReadToolOptions { function formatReadCall( args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: Theme, ): string { const rawPath = str(args?.file_path ?? args?.path); const path = rawPath !== null ? shortenPath(rawPath) : null; @@ -85,15 +108,87 @@ function getNonVisionImageNote(model: Model | undefined): string | undefine return "[Current model does not support images. The image will be omitted from this request.]"; } +function toPosixPath(filePath: string): string { + return filePath.split(sep).join("/"); +} + +function getPiDocsClassification(absolutePath: string): CompactReadClassification | undefined { + const packageRoot = dirname(getReadmePath()); + const relativePath = relative(resolvePath(packageRoot), resolvePath(absolutePath)); + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return undefined; + } + + const label = toPosixPath(relativePath); + if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) { + return { kind: "docs", label }; + } + return undefined; +} + +function getCompactReadClassification( + args: { path?: string; file_path?: string } | undefined, + cwd: string, +): CompactReadClassification | undefined { + const rawPath = str(args?.file_path ?? args?.path); + if (!rawPath) return undefined; + + const absolutePath = resolveReadPath(rawPath, cwd); + const fileName = basename(absolutePath); + if (fileName === "SKILL.md") { + return { kind: "skill", label: basename(dirname(absolutePath)) || fileName }; + } + + const docsClassification = getPiDocsClassification(absolutePath); + if (docsClassification) return docsClassification; + + if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) { + return { kind: "resource", label: fileName }; + } + + return undefined; +} + +function formatCompactReadResult(classification: CompactReadClassification, theme: Theme): string { + if (classification.kind === "skill") { + return ( + theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + + theme.fg("customMessageText", classification.label) + + theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`) + ); + } + + return ( + theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) + + " " + + theme.fg("accent", classification.label) + + theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`) + ); +} + function formatReadResult( args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined, result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: Theme, showImages: boolean, + cwd: string, + isError: boolean, ): string { + if (!options.expanded && !isError) { + const classification = getCompactReadClassification(args, cwd); + if (classification) { + return formatCompactReadResult(classification, theme); + } + } + const rawPath = str(args?.file_path ?? args?.path); - const output = getTextOutput(result as any, showImages); + const output = getTextOutput(result, showImages); const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); const lines = trimTrailingEmptyLines(renderedLines); @@ -121,7 +216,7 @@ function formatReadResult( export function createReadToolDefinition( cwd: string, options?: ReadToolOptions, -): ToolDefinition { +): ToolDefinition { const autoResizeImages = options?.autoResizeImages ?? true; const ops = options?.operations ?? defaultReadOperations; return { @@ -256,13 +351,22 @@ export function createReadToolDefinition( ); }, renderCall(args, theme, context) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const text = + context.lastComponent instanceof ReadCallText ? context.lastComponent : new ReadCallText(context.state); + context.state.hideCall = false; text.setText(formatReadCall(args, theme)); return text; }, renderResult(result, options, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatReadResult(context.args, result as any, options, theme, context.showImages)); + const hideCall = + !options.expanded && + !context.isError && + getCompactReadClassification(context.args, context.cwd) !== undefined; + text.setText( + formatReadResult(context.args, result, options, theme, context.showImages, context.cwd, context.isError), + ); + context.state.hideCall = hideCall; return text; }, }; diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 04a9f7a9..6c37b65e 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -1,7 +1,9 @@ +import { join } from "node:path"; import { Text, type TUI } from "@mariozechner/pi-tui"; import stripAnsi from "strip-ansi"; import { Type } from "typebox"; import { beforeAll, describe, expect, test } from "vitest"; +import { getReadmePath } from "../src/config.js"; import type { ToolDefinition } from "../src/core/extensions/types.js"; import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js"; import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.js"; @@ -145,7 +147,7 @@ describe("ToolExecutionComponent parity", () => { const component = new ToolExecutionComponent( "read", "tool-4b", - { path: "README.md" }, + { path: "notes.txt" }, {}, overrideDefinition, createFakeTui(), @@ -313,7 +315,7 @@ describe("ToolExecutionComponent parity", () => { const component = new ToolExecutionComponent( "read", "tool-8", - { path: "README.md" }, + { path: "notes.txt" }, {}, createReadToolDefinition(process.cwd()), createFakeTui(), @@ -328,4 +330,58 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).toContain("two"); expect(rendered).not.toContain("two\n\n"); }); + + for (const scenario of [ + { + title: "SKILL.md", + path: join(process.cwd(), "attio", "SKILL.md"), + content: "---\nname: attio\ndescription: CRM helper\n---\n\n# Hidden skill instructions", + compact: "[skill] attio", + hidden: "Hidden skill instructions", + absent: "read skill attio", + }, + { + title: "AGENTS.md", + path: join(process.cwd(), "AGENTS.md"), + content: "Hidden resource instructions", + compact: "read resource AGENTS.md", + hidden: "Hidden resource instructions", + absent: undefined, + }, + { + title: "Pi documentation", + path: getReadmePath(), + content: "Hidden docs content", + compact: "read docs README.md", + hidden: "Hidden docs content", + absent: undefined, + }, + ] as const) { + test(`renders ${scenario.title} read results compactly until expanded`, () => { + const component = new ToolExecutionComponent( + "read", + `tool-compact-${scenario.title}`, + { path: scenario.path }, + {}, + createReadToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + component.updateResult( + { content: [{ type: "text", text: scenario.content }], details: undefined, isError: false }, + false, + ); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).toContain(scenario.compact); + expect(collapsed).not.toContain(scenario.hidden); + if (scenario.absent) { + expect(collapsed).not.toContain(scenario.absent); + } + + component.setExpanded(true); + const expanded = stripAnsi(component.render(120).join("\n")); + expect(expanded).toContain(scenario.hidden); + }); + } }); From cd5bfd04d741581c515835f9a753f44ec6dd10cb Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 2 May 2026 23:46:37 +0200 Subject: [PATCH 02/12] fix(ci): install sandbox runtime for root typecheck --- package-lock.json | 117 ++++++++++++++++++++++++++-------------------- package.json | 1 + 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37cef1ca..8fa4f1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "get-east-asian-width": "^1.4.0" }, "devDependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.26", "@biomejs/biome": "2.3.5", "@types/node": "^22.10.5", "@typescript/native-preview": "7.0.0-dev.20260120.1", @@ -34,6 +35,44 @@ "node": ">=20.0.0" } }, + "node_modules/@anthropic-ai/sandbox-runtime": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz", + "integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==", + "license": "Apache-2.0", + "dependencies": { + "@pondwader/socks5-server": "^1.0.10", + "@types/lodash-es": "^4.17.12", + "commander": "^12.1.0", + "lodash-es": "^4.17.21", + "shell-quote": "^1.8.3", + "zod": "^3.24.1" + }, + "bin": { + "srt": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@anthropic-ai/sandbox-runtime/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@anthropic-ai/sandbox-runtime/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.91.1", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", @@ -2434,6 +2473,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@pondwader/socks5-server": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", + "license": "MIT" + }, "node_modules/@preact/signals-core": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.1.tgz", @@ -3820,6 +3865,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mime-types": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", @@ -6035,6 +6095,12 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -8252,57 +8318,6 @@ "@anthropic-ai/sandbox-runtime": "^0.0.26" } }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.26", - "license": "Apache-2.0", - "dependencies": { - "@pondwader/socks5-server": "^1.0.10", - "@types/lodash-es": "^4.17.12", - "commander": "^12.1.0", - "lodash-es": "^4.17.21", - "shell-quote": "^1.8.3", - "zod": "^3.24.1" - }, - "bin": { - "srt": "dist/cli.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@pondwader/socks5-server": { - "version": "1.0.10", - "license": "MIT" - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@types/lodash": { - "version": "4.17.23", - "license": "MIT" - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@types/lodash-es": { - "version": "4.17.12", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/commander": { - "version": "12.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/lodash-es": { - "version": "4.18.1", - "license": "MIT" - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/zod": { - "version": "3.25.76", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", "version": "0.72.1", diff --git a/package.json b/package.json index 6ba7e9db..332259c3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "prepare": "husky" }, "devDependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.26", "@biomejs/biome": "2.3.5", "@types/node": "^22.10.5", "@typescript/native-preview": "7.0.0-dev.20260120.1", From b9efafc5351a746108adb9b135564c574f8f8cfd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 3 May 2026 00:04:27 +0200 Subject: [PATCH 03/12] fix(ci): repair failing test expectations --- packages/ai/src/providers/amazon-bedrock.ts | 7 +++++++ packages/coding-agent/test/package-manager.test.ts | 12 +++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index e4200e9d..1812d3c8 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -494,10 +494,17 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean return candidates.some((s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6")); } +function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { + const candidates = getModelMatchCandidates(model.id, model.name); + return candidates.some((s) => s.includes("opus-4-7")); +} + function mapThinkingLevelToEffort( model: Model<"bedrock-converse-stream">, level: SimpleStreamOptions["reasoning"], ): "low" | "medium" | "high" | "xhigh" | "max" { + if (level === "xhigh" && supportsNativeXhighEffort(model)) return "xhigh"; + const mapped = level ? model.thinkingLevelMap?.[level] : undefined; if (typeof mapped === "string") return mapped as "low" | "medium" | "high" | "xhigh" | "max"; diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 629e8b84..ca0ffd75 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -6,6 +6,7 @@ import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; +import { shouldUseWindowsShell } from "../src/utils/child-process.js"; function normalizeForMatch(value: string): string { return value.replace(/\\/g, "/"); @@ -520,14 +521,11 @@ Content`, describe("windows command spawning", () => { it("should avoid the shell for git so Windows paths with spaces stay single arguments", () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const managerWithInternals = packageManager as unknown as { - shouldUseWindowsShell(command: string): boolean; - }; - expect(managerWithInternals.shouldUseWindowsShell("git")).toBe(false); - expect(managerWithInternals.shouldUseWindowsShell("npm")).toBe(true); - expect(managerWithInternals.shouldUseWindowsShell("pnpm")).toBe(true); - expect(managerWithInternals.shouldUseWindowsShell("C:/Program Files/nodejs/npm.cmd")).toBe(true); + expect(shouldUseWindowsShell("git")).toBe(false); + expect(shouldUseWindowsShell("npm")).toBe(true); + expect(shouldUseWindowsShell("pnpm")).toBe(true); + expect(shouldUseWindowsShell("C:/Program Files/nodejs/npm.cmd")).toBe(true); }); }); From c8edb256b9dc20f6fba798e5b90ef304b9e85d10 Mon Sep 17 00:00:00 2001 From: Jakub Synowiec Date: Sun, 3 May 2026 00:41:55 +0200 Subject: [PATCH 04/12] fix(ai): fix mismatch between models.dev and OpenCode Go (Qwen3.5/3.6, MiniMax M2.7) (#4110) --- packages/ai/scripts/generate-models.ts | 20 ++++++++++++++++++++ packages/ai/src/models.generated.ts | 22 ++++++++++++---------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 72a70d27..b9692801 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -724,6 +724,26 @@ async function loadModelsDevData(): Promise[]> { baseUrl = `${variant.basePath}/v1`; } + // Fix known mismatches between models.dev npm data and actual + // OpenCode Go endpoint behaviour. models.dev reports these models + // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't + // accept Anthropic SDK auth (MiniMax M2.7) or are served through + // the OpenAI-compatible /v1/chat/completions path (Qwen 3.5/3.6). + // Switch them to openai-completions so requests use Bearer auth + // and the standard /v1/chat/completions endpoint. + if (variant.provider === "opencode-go") { + if (modelId === "minimax-m2.7") { + api = "openai-completions"; + baseUrl = `${variant.basePath}/v1`; + } + if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") { + api = "openai-completions"; + baseUrl = `${variant.basePath}/v1`; + // Qwen/DashScope uses enable_thinking at the top level. + compat = { ...(compat ?? {}), thinkingFormat: "qwen" }; + } + } + models.push({ id: modelId, name: m.name || modelId, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9951ccae..d78b0274 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -8463,9 +8463,9 @@ export const MODELS = { "minimax-m2.7": { id: "minimax-m2.7", name: "MiniMax M2.7", - api: "anthropic-messages", + api: "openai-completions", provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go", + baseUrl: "https://opencode.ai/zen/go/v1", reasoning: true, input: ["text"], cost: { @@ -8476,13 +8476,14 @@ export const MODELS = { }, contextWindow: 204800, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "qwen3.5-plus": { id: "qwen3.5-plus", name: "Qwen3.5 Plus", - api: "anthropic-messages", + api: "openai-completions", provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"thinkingFormat":"qwen"}, reasoning: true, input: ["text", "image"], cost: { @@ -8493,13 +8494,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "qwen3.6-plus": { id: "qwen3.6-plus", name: "Qwen3.6 Plus", - api: "anthropic-messages", + api: "openai-completions", provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"thinkingFormat":"qwen"}, reasoning: true, input: ["text", "image"], cost: { @@ -8510,7 +8512,7 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, }, "openrouter": { "ai21/jamba-large-1.7": { @@ -9316,7 +9318,7 @@ export const MODELS = { cacheRead: 0.024999999999999998, cacheWrite: 0.08333333333333334, }, - contextWindow: 1048576, + contextWindow: 1000000, maxTokens: 8192, } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-lite-001": { From 7c5ef0b70e83bb408b3f47785cce017940b06841 Mon Sep 17 00:00:00 2001 From: myu003 Date: Sun, 3 May 2026 18:55:23 +0800 Subject: [PATCH 05/12] test(ai,coding-agent): stabilize env-sensitive test cases (#4119) --- packages/ai/test/openai-codex-stream.test.ts | 4 +- packages/coding-agent/test/clipboard.test.ts | 3 + .../coding-agent/test/package-manager.test.ts | 101 ++++++++++-------- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 49981cbf..f3d19896 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -242,7 +242,7 @@ describe("openai-codex streaming", () => { }; const result = await Promise.race([ - streamOpenAICodexResponses(model, context, { apiKey: token }).result(), + streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(), new Promise((_, reject) => { setTimeout(() => reject(new Error("Timed out waiting for completed SSE stream")), 1000); }), @@ -301,7 +301,7 @@ describe("openai-codex streaming", () => { }; const result = await Promise.race([ - streamOpenAICodexResponses(model, context, { apiKey: token }).result(), + streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(), new Promise((_, reject) => { setTimeout(() => reject(new Error("Timed out waiting for incomplete SSE stream")), 1000); }), diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index 380d6a70..ea231b57 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -54,6 +54,9 @@ function osc52Writes(): string[] { beforeEach(() => { vi.unstubAllEnvs(); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("SSH_CLIENT", ""); + vi.stubEnv("MOSH_CONNECTION", ""); stdoutWrites = []; nativeResolved = false; mocks.clipboard.setText.mockReset(); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index ca0ffd75..636877b3 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -160,60 +160,71 @@ Content`, }); it("should resolve symlinked user and project resources once", async () => { - const sharedDir = join(tempDir, "shared-resources"); - const sharedExtensionsDir = join(sharedDir, "extensions"); - const sharedSkillsDir = join(sharedDir, "skills"); - const sharedPromptsDir = join(sharedDir, "prompts"); - const sharedThemesDir = join(sharedDir, "themes"); - mkdirSync(sharedExtensionsDir, { recursive: true }); - mkdirSync(sharedSkillsDir, { recursive: true }); - mkdirSync(sharedPromptsDir, { recursive: true }); - mkdirSync(sharedThemesDir, { recursive: true }); + const previousHome = process.env.HOME; + process.env.HOME = tempDir; - writeFileSync(join(sharedExtensionsDir, "shared.ts"), "export default function() {}"); - mkdirSync(join(sharedSkillsDir, "shared-skill"), { recursive: true }); - writeFileSync( - join(sharedSkillsDir, "shared-skill", "SKILL.md"), - `--- + try { + const sharedDir = join(tempDir, "shared-resources"); + const sharedExtensionsDir = join(sharedDir, "extensions"); + const sharedSkillsDir = join(sharedDir, "skills"); + const sharedPromptsDir = join(sharedDir, "prompts"); + const sharedThemesDir = join(sharedDir, "themes"); + mkdirSync(sharedExtensionsDir, { recursive: true }); + mkdirSync(sharedSkillsDir, { recursive: true }); + mkdirSync(sharedPromptsDir, { recursive: true }); + mkdirSync(sharedThemesDir, { recursive: true }); + + writeFileSync(join(sharedExtensionsDir, "shared.ts"), "export default function() {}"); + mkdirSync(join(sharedSkillsDir, "shared-skill"), { recursive: true }); + writeFileSync( + join(sharedSkillsDir, "shared-skill", "SKILL.md"), + `--- name: shared-skill description: Shared skill --- Content`, - ); - writeFileSync(join(sharedPromptsDir, "shared.md"), "Shared prompt"); - writeFileSync(join(sharedThemesDir, "shared.json"), JSON.stringify({ name: "shared-theme" })); + ); + writeFileSync(join(sharedPromptsDir, "shared.md"), "Shared prompt"); + writeFileSync(join(sharedThemesDir, "shared.json"), JSON.stringify({ name: "shared-theme" })); - mkdirSync(join(agentDir), { recursive: true }); - mkdirSync(join(tempDir, ".pi"), { recursive: true }); - symlinkSync(sharedExtensionsDir, join(agentDir, "extensions"), "dir"); - symlinkSync(sharedSkillsDir, join(agentDir, "skills"), "dir"); - symlinkSync(sharedPromptsDir, join(agentDir, "prompts"), "dir"); - symlinkSync(sharedThemesDir, join(agentDir, "themes"), "dir"); - symlinkSync(sharedExtensionsDir, join(tempDir, ".pi", "extensions"), "dir"); - symlinkSync(sharedSkillsDir, join(tempDir, ".pi", "skills"), "dir"); - symlinkSync(sharedPromptsDir, join(tempDir, ".pi", "prompts"), "dir"); - symlinkSync(sharedThemesDir, join(tempDir, ".pi", "themes"), "dir"); + mkdirSync(join(agentDir), { recursive: true }); + mkdirSync(join(tempDir, ".pi"), { recursive: true }); + symlinkSync(sharedExtensionsDir, join(agentDir, "extensions"), "dir"); + symlinkSync(sharedSkillsDir, join(agentDir, "skills"), "dir"); + symlinkSync(sharedPromptsDir, join(agentDir, "prompts"), "dir"); + symlinkSync(sharedThemesDir, join(agentDir, "themes"), "dir"); + symlinkSync(sharedExtensionsDir, join(tempDir, ".pi", "extensions"), "dir"); + symlinkSync(sharedSkillsDir, join(tempDir, ".pi", "skills"), "dir"); + symlinkSync(sharedPromptsDir, join(tempDir, ".pi", "prompts"), "dir"); + symlinkSync(sharedThemesDir, join(tempDir, ".pi", "themes"), "dir"); - const result = await packageManager.resolve(); + const result = await packageManager.resolve(); - expect({ - extensions: result.extensions.length, - skills: result.skills.length, - prompts: result.prompts.length, - themes: result.themes.length, - }).toEqual({ - extensions: 1, - skills: 1, - prompts: 1, - themes: 1, - }); + expect({ + extensions: result.extensions.length, + skills: result.skills.length, + prompts: result.prompts.length, + themes: result.themes.length, + }).toEqual({ + extensions: 1, + skills: 1, + prompts: 1, + themes: 1, + }); - // Project auto-discovered has higher precedence than user auto-discovered, - // so the surviving entry should be scoped to project. - expect(result.extensions[0].metadata.scope).toBe("project"); - expect(result.skills[0].metadata.scope).toBe("project"); - expect(result.prompts[0].metadata.scope).toBe("project"); - expect(result.themes[0].metadata.scope).toBe("project"); + // Project auto-discovered has higher precedence than user auto-discovered, + // so the surviving entry should be scoped to project. + expect(result.extensions[0].metadata.scope).toBe("project"); + expect(result.skills[0].metadata.scope).toBe("project"); + expect(result.prompts[0].metadata.scope).toBe("project"); + expect(result.themes[0].metadata.scope).toBe("project"); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } }); it("should auto-discover project prompts with overrides", async () => { From 693888ac47988419061d56712576c7fac04446c8 Mon Sep 17 00:00:00 2001 From: Jake Jia <87770044+Phoen1xCode@users.noreply.github.com> Date: Sun, 3 May 2026 18:57:11 +0800 Subject: [PATCH 06/12] feat(ai): switch xiaomi default to api billing, add per-region token plan providers (#4112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built-in `xiaomi` provider now targets the API billing endpoint (https://api.xiaomimimo.com/anthropic) — a single stable URL for keys issued at platform.xiaomimimo.com. The Token Plan endpoints are exposed as three sibling providers, each with its own env var: - xiaomi-token-plan-cn: XIAOMI_TOKEN_PLAN_CN_API_KEY - xiaomi-token-plan-ams: XIAOMI_TOKEN_PLAN_AMS_API_KEY - xiaomi-token-plan-sgp: XIAOMI_TOKEN_PLAN_SGP_API_KEY BREAKING CHANGE: users who previously set XIAOMI_API_KEY against the Token Plan AMS endpoint must move to xiaomi-token-plan-ams and set XIAOMI_TOKEN_PLAN_AMS_API_KEY. This also resolves the 401 reported by on #4005, where a platform.xiaomimimo.com key fails against the Token Plan endpoint. closes #4082 --- packages/ai/CHANGELOG.md | 8 + packages/ai/README.md | 7 +- packages/ai/scripts/generate-models.ts | 54 ++-- packages/ai/src/env-api-keys.ts | 3 + packages/ai/src/models.generated.ts | 269 +++++++++++++++++- packages/ai/src/types.ts | 5 +- packages/ai/test/abort.test.ts | 38 ++- packages/ai/test/context-overflow.test.ts | 38 ++- .../ai/test/cross-provider-handoff.test.ts | 3 + packages/ai/test/empty.test.ts | 71 ++++- packages/ai/test/image-tool-result.test.ts | 62 +++- packages/ai/test/stream.test.ts | 96 ++++++- packages/ai/test/tokens.test.ts | 39 ++- .../ai/test/tool-call-without-result.test.ts | 26 +- packages/ai/test/total-tokens.test.ts | 77 ++++- packages/ai/test/unicode-surrogate.test.ts | 71 ++++- packages/coding-agent/CHANGELOG.md | 8 + packages/coding-agent/README.md | 5 +- packages/coding-agent/docs/providers.md | 12 +- packages/coding-agent/src/cli/args.ts | 5 +- .../coding-agent/src/core/model-resolver.ts | 3 + .../src/core/provider-display-names.ts | 5 +- 22 files changed, 859 insertions(+), 46 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fcc13668..1be5a737 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Switched the built-in `xiaomi` provider endpoint from Token Plan AMS (`https://token-plan-ams.xiaomimimo.com/anthropic`) to API billing (`https://api.xiaomimimo.com/anthropic`). `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users still on Token Plan must move to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var. + +### Added + +- Added Xiaomi MiMo Token Plan regional providers with per-region env vars: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), and `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`). + ## [0.72.1] - 2026-05-02 ## [0.72.0] - 2026-05-01 diff --git a/packages/ai/README.md b/packages/ai/README.md index 3704902d..51b3ef53 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -69,7 +69,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **OpenCode Go** - **Fireworks** (uses Anthropic-compatible API) - **Kimi For Coding** (Moonshot AI, uses Anthropic-compatible API) -- **Xiaomi MiMo Token Plan** (uses Anthropic-compatible API) +- **Xiaomi MiMo** (uses Anthropic-compatible API; defaults to API billing endpoint, with separate Token Plan providers for `cn`/`ams`/`sgp` regions) - **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc. ## Installation @@ -1047,7 +1047,10 @@ In Node.js environments, you can set environment variables to avoid passing API | MiniMax | `MINIMAX_API_KEY` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | -| Xiaomi MiMo Token Plan | `XIAOMI_API_KEY` | +| Xiaomi MiMo (API billing) | `XIAOMI_API_KEY` | +| Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | +| Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | +| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | | GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` | When set, the library automatically uses these keys: diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b9692801..e9c17322 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -931,28 +931,40 @@ async function loadModelsDevData(): Promise[]> { } // Process Xiaomi MiMo models - if (data.xiaomi?.models) { - for (const [modelId, model] of Object.entries(data.xiaomi.models)) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; + // Built-in `xiaomi` targets the API billing endpoint (single stable URL, + // keys from platform.xiaomimimo.com). The three `xiaomi-token-plan-*` + // providers cover prepaid Token Plan endpoints in cn / ams / sgp. + const xiaomiVariants = [ + { provider: "xiaomi", baseUrl: "https://api.xiaomimimo.com/anthropic" }, + { provider: "xiaomi-token-plan-cn", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic" }, + { provider: "xiaomi-token-plan-ams", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic" }, + { provider: "xiaomi-token-plan-sgp", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic" }, + ] as const; - models.push({ - id: modelId, - name: m.name || modelId, - api: "anthropic-messages", - provider: "xiaomi", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", - reasoning: m.reasoning === true, - input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], - cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); + if (data.xiaomi?.models) { + for (const { provider, baseUrl } of xiaomiVariants) { + for (const [modelId, model] of Object.entries(data.xiaomi.models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "anthropic-messages", + provider, + baseUrl, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } } } diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 0f41d542..b5b2ea81 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -123,6 +123,9 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", xiaomi: "XIAOMI_API_KEY", + "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", }; const envVar = envMap[provider]; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index d78b0274..c4c41bec 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -16396,7 +16396,7 @@ export const MODELS = { name: "MiMo-V2-Flash", api: "anthropic-messages", provider: "xiaomi", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/anthropic", reasoning: true, input: ["text"], cost: { @@ -16413,7 +16413,7 @@ export const MODELS = { name: "MiMo-V2-Omni", api: "anthropic-messages", provider: "xiaomi", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/anthropic", reasoning: true, input: ["text", "image"], cost: { @@ -16430,7 +16430,7 @@ export const MODELS = { name: "MiMo-V2-Pro", api: "anthropic-messages", provider: "xiaomi", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/anthropic", reasoning: true, input: ["text"], cost: { @@ -16447,7 +16447,7 @@ export const MODELS = { name: "MiMo-V2.5", api: "anthropic-messages", provider: "xiaomi", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/anthropic", reasoning: true, input: ["text"], cost: { @@ -16464,8 +16464,269 @@ export const MODELS = { name: "MiMo-V2.5-Pro", api: "anthropic-messages", provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + }, + "xiaomi-token-plan-ams": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + }, + "xiaomi-token-plan-cn": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "anthropic-messages", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "anthropic-messages", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "anthropic-messages", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + }, + "xiaomi-token-plan-sgp": { + "mimo-v2-flash": { + id: "mimo-v2-flash", + name: "MiMo-V2-Flash", + api: "anthropic-messages", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "mimo-v2-omni": { + id: "mimo-v2-omni", + name: "MiMo-V2-Omni", + api: "anthropic-messages", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "mimo-v2-pro": { + id: "mimo-v2-pro", + name: "MiMo-V2-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "mimo-v2.5": { + id: "mimo-v2.5", + name: "MiMo-V2.5", + api: "anthropic-messages", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "mimo-v2.5-pro": { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + reasoning: true, input: ["text", "image"], cost: { input: 1, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 60502150..2ed5da10 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -43,7 +43,10 @@ export type KnownProvider = | "kimi-coding" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" - | "xiaomi"; + | "xiaomi" + | "xiaomi-token-plan-cn" + | "xiaomi-token-plan-ams" + | "xiaomi-token-plan-sgp"; export type Provider = KnownProvider | string; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 8d0311f1..57ef078a 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -190,7 +190,7 @@ describe("AI Providers Abort Tests", () => { }); }); - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo Provider Abort", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Abort", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); it("should abort mid-stream", { retry: 3 }, async () => { @@ -202,6 +202,42 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN) Provider Abort", () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS) Provider Abort", () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP) Provider Abort", () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Abort", () => { const llm = getModel("kimi-coding", "kimi-k2-thinking"); diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 9db4ac50..9a62453b 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -393,7 +393,7 @@ describe("Context overflow error handling", () => { // Xiaomi MiMo // ============================================================================= - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing)", () => { // Xiaomi silently truncates oversized input to fill the context window exactly, // then returns finish_reason "length" with output=0 (no room left to generate). // This is a detectable overflow signal but uses stopReason "length" rather than "error". @@ -408,6 +408,42 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN)", () => { + it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => { + const model = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + const result = await testContextOverflow(model, process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("length"); + expect(result.usage.output).toBe(0); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS)", () => { + it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => { + const model = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + const result = await testContextOverflow(model, process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("length"); + expect(result.usage.output).toBe(0); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP)", () => { + it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => { + const model = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + const result = await testContextOverflow(model, process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("length"); + expect(result.usage.output).toBe(0); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + // ============================================================================= // Kimi For Coding // ============================================================================= diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index d376725c..542d2345 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -126,6 +126,9 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ { provider: "opencode-go", model: "minimax-m2.5", label: "go-minimax-m2.5" }, // Xiaomi MiMo { provider: "xiaomi", model: "mimo-v2.5-pro", label: "xiaomi-mimo-v2.5-pro" }, + { provider: "xiaomi-token-plan-cn", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-cn-mimo-v2.5-pro" }, + { provider: "xiaomi-token-plan-ams", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-ams-mimo-v2.5-pro" }, + { provider: "xiaomi-token-plan-sgp", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-sgp-mimo-v2.5-pro" }, ]; // Cached context structure diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index d34c14b3..560468bd 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -427,7 +427,7 @@ describe("AI Providers Empty Message Tests", () => { }); }); - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo Provider Empty Messages", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Empty Messages", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { @@ -447,6 +447,75 @@ describe("AI Providers Empty Message Tests", () => { }); }); + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)( + "Xiaomi MiMo Token Plan (CN) Provider Empty Messages", + () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)( + "Xiaomi MiMo Token Plan (AMS) Provider Empty Messages", + () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)( + "Xiaomi MiMo Token Plan (SGP) Provider Empty Messages", + () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }, + ); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => { const llm = getModel("kimi-coding", "kimi-k2-thinking"); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index b4dfb7cc..48f62aa4 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -298,18 +298,76 @@ describe("Tool Results with Images", () => { }); }); - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo Provider (mimo-v2.5-pro)", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider (mimo-v2.5-pro)", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithImageResult(llm); }); - it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + // FIXME(xiaomi): when a tool_result contains both a descriptive text block + // and an image block, MiMo locks onto the text and ignores the image (it + // reports the text-derived diameter but never mentions the image's color). + // The image-only case above proves the image reaches the model, and the + // text-only path obviously works, so this is a multimodal-fusion quality + // issue in the model, not a transport bug. Re-enable when upstream model + // quality improves. + it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithTextAndImageResult(llm); }); }); + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)( + "Xiaomi MiMo Token Plan (CN) Provider (mimo-v2.5-pro)", + () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + // FIXME(xiaomi): see the API-billing block above — same multimodal-fusion + // limitation applies to Token Plan endpoints (same model behind both). + it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)( + "Xiaomi MiMo Token Plan (AMS) Provider (mimo-v2.5-pro)", + () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + // FIXME(xiaomi): see the API-billing block above — same multimodal-fusion + // limitation applies to Token Plan endpoints (same model behind both). + it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)( + "Xiaomi MiMo Token Plan (SGP) Provider (mimo-v2.5-pro)", + () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + // FIXME(xiaomi): see the API-billing block above — same multimodal-fusion + // limitation applies to Token Plan endpoints (same model behind both). + it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }, + ); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (kimi-for-coding)", () => { const llm = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 09600460..910c1a02 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -994,7 +994,7 @@ describe("Generate E2E Tests", () => { ); describe.skipIf(!process.env.XIAOMI_API_KEY)( - "Xiaomi MiMo Token Plan Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages)", + "Xiaomi MiMo (API billing) Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages)", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); const thinkingOptions = { @@ -1023,6 +1023,100 @@ describe("Generate E2E Tests", () => { }); }, ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)( + "Xiaomi MiMo Token Plan Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages, CN region)", + () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)( + "Xiaomi MiMo Token Plan Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages, AMS region)", + () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)( + "Xiaomi MiMo Token Plan Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages, SGP region)", + () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + // ========================================================================= // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // Tokens are resolved at module level (see oauthTokens above) diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index fe550dae..eca33524 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -220,10 +220,45 @@ describe("Token Statistics on Abort", () => { }); }); - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo Provider", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); - it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + // FIXME(xiaomi): Xiaomi's Anthropic-compatible stream does not populate + // usage in the message_start event the way Anthropic does — usage only + // arrives at message_stop. Aborting mid-stream therefore loses input/output + // token counts. Non-streaming usage works (see total-tokens.test.ts). + // Re-enable once upstream sends usage in message_start. + it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN) Provider", () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + // FIXME(xiaomi): see the API-billing block above — same upstream streaming + // usage limitation applies to Token Plan endpoints. + it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS) Provider", () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + // FIXME(xiaomi): see the API-billing block above — same upstream streaming + // usage limitation applies to Token Plan endpoints. + it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP) Provider", () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + // FIXME(xiaomi): see the API-billing block above — same upstream streaming + // usage limitation applies to Token Plan endpoints. + it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); }); }); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index e65b2c05..532a3760 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -215,7 +215,7 @@ describe("Tool Call Without Result Tests", () => { }); }); - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo Provider", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider", () => { const model = getModel("xiaomi", "mimo-v2.5-pro"); it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { @@ -223,6 +223,30 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN) Provider", () => { + const model = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS) Provider", () => { + const model = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP) Provider", () => { + const model = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider", () => { const model = getModel("kimi-coding", "kimi-k2-thinking"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index be02bfd6..658b6aeb 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -447,7 +447,7 @@ describe("totalTokens field", () => { // Xiaomi MiMo // ========================================================================= - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing)", () => { it( "mimo-v2.5-pro - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, @@ -466,6 +466,81 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Xiaomi MiMo Token Plan CN + // ========================================================================= + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN)", () => { + it( + "mimo-v2.5-pro - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + console.log(`\nXiaomi MiMo Token Plan CN / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + + // ========================================================================= + // Xiaomi MiMo Token Plan AMS + // ========================================================================= + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS)", () => { + it( + "mimo-v2.5-pro - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + console.log(`\nXiaomi MiMo Token Plan AMS / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + + // ========================================================================= + // Xiaomi MiMo Token Plan SGP + // ========================================================================= + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP)", () => { + it( + "mimo-v2.5-pro - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + console.log(`\nXiaomi MiMo Token Plan SGP / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + // ========================================================================= // Kimi For Coding // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 5bd7229e..bf044f35 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -594,7 +594,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo Provider Unicode Handling", () => { + describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Unicode Handling", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { @@ -610,6 +610,75 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)( + "Xiaomi MiMo Token Plan (CN) Provider Unicode Handling", + () => { + const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it( + "should handle unpaired high surrogate (0xD83D) in tool results", + { retry: 3, timeout: 30000 }, + async () => { + await testUnpairedHighSurrogate(llm); + }, + ); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)( + "Xiaomi MiMo Token Plan (AMS) Provider Unicode Handling", + () => { + const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it( + "should handle unpaired high surrogate (0xD83D) in tool results", + { retry: 3, timeout: 30000 }, + async () => { + await testUnpairedHighSurrogate(llm); + }, + ); + }, + ); + + describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)( + "Xiaomi MiMo Token Plan (SGP) Provider Unicode Handling", + () => { + const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it( + "should handle unpaired high surrogate (0xD83D) in tool results", + { retry: 3, timeout: 30000 }, + async () => { + await testUnpairedHighSurrogate(llm); + }, + ); + }, + ); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Unicode Handling", () => { const llm = getModel("kimi-coding", "kimi-k2-thinking"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5b3dc615..77c3dea5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Switched the built-in `xiaomi` provider from Token Plan AMS to Xiaomi's API billing endpoint, and renamed its `/login` display from "Xiaomi MiMo Token Plan" to "Xiaomi MiMo". `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users on Token Plan should switch to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var. + +### Added + +- Added three Xiaomi MiMo Token Plan regional providers visible in `/login`: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`). Each defaults to `mimo-v2.5-pro`. + ### Changed - Changed `read` tool rendering to collapse Pi documentation, AGENTS/CLAUDE context files, and `SKILL.md` contents by default in interactive output. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 70820fd2..4dd23786 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -128,7 +128,10 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - Fireworks - Kimi For Coding - MiniMax -- Xiaomi MiMo Token Plan +- Xiaomi MiMo +- Xiaomi MiMo Token Plan (China) +- Xiaomi MiMo Token Plan (Amsterdam) +- Xiaomi MiMo Token Plan (Singapore) See [docs/providers.md](docs/providers.md) for detailed setup instructions. diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index c11874b4..23bb9b96 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -69,9 +69,10 @@ pi | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | -| Xiaomi MiMo Token Plan | `XIAOMI_API_KEY` | `xiaomi` | - -The Xiaomi MiMo provider targets the Xiaomi MiMo Token Plan Anthropic-compatible endpoint. +| Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | +| Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` | +| Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` | +| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | `xiaomi-token-plan-sgp` | Reference for environment variables and `auth.json` keys: [`const envMap`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/env-api-keys.ts) in [`packages/ai/src/env-api-keys.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/env-api-keys.ts). @@ -87,7 +88,10 @@ Store credentials in `~/.pi/agent/auth.json`: "google": { "type": "api_key", "key": "..." }, "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." }, - "xiaomi": { "type": "api_key", "key": "..." } + "xiaomi": { "type": "api_key", "key": "..." }, + "xiaomi-token-plan-cn": { "type": "api_key", "key": "..." }, + "xiaomi-token-plan-ams": { "type": "api_key", "key": "..." }, + "xiaomi-token-plan-sgp": { "type": "api_key", "key": "..." } } ``` diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8f281ffe..6c451ccb 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -320,7 +320,10 @@ ${chalk.bold("Environment Variables:")} CLOUDFLARE_API_KEY - Cloudflare API token (Workers AI and AI Gateway) CLOUDFLARE_ACCOUNT_ID - Cloudflare account id (required for both) CLOUDFLARE_GATEWAY_ID - Cloudflare AI Gateway slug (required for AI Gateway) - XIAOMI_API_KEY - Xiaomi MiMo Token Plan API key + XIAOMI_API_KEY - Xiaomi MiMo API key (api.xiaomimimo.com billing) + XIAOMI_TOKEN_PLAN_CN_API_KEY - Xiaomi MiMo Token Plan API key (China region) + XIAOMI_TOKEN_PLAN_AMS_API_KEY - Xiaomi MiMo Token Plan API key (Amsterdam region) + XIAOMI_TOKEN_PLAN_SGP_API_KEY - Xiaomi MiMo Token Plan API key (Singapore region) AWS_PROFILE - AWS profile for Amazon Bedrock AWS_ACCESS_KEY_ID - AWS access key for Amazon Bedrock AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 3bbfc461..3619d17a 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -40,6 +40,9 @@ export const defaultModelPerProvider: Record = { "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", xiaomi: "mimo-v2.5-pro", + "xiaomi-token-plan-cn": "mimo-v2.5-pro", + "xiaomi-token-plan-ams": "mimo-v2.5-pro", + "xiaomi-token-plan-sgp": "mimo-v2.5-pro", }; export interface ScopedModel { diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index a07e5ecb..6a5f0e2a 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -24,5 +24,8 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "vercel-ai-gateway": "Vercel AI Gateway", xai: "xAI", zai: "ZAI", - xiaomi: "Xiaomi MiMo Token Plan", + xiaomi: "Xiaomi MiMo", + "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", + "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", + "xiaomi-token-plan-sgp": "Xiaomi MiMo Token Plan (Singapore)", }; From 21026cb7990f3609eaa02587a5b1ba8bed0d03f4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 3 May 2026 22:04:05 +0200 Subject: [PATCH 07/12] chore: update temporary issue gate message --- .github/workflows/issue-gate.yml | 45 ++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index e6491b99..4aa603ef 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -17,6 +17,7 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const ISSUE_GATE_MESSAGE_MODE = 'refactor'; // Switch to 'normal' to restore the standard auto-close message. const issueAuthor = context.payload.issue.user.login; const defaultBranch = context.payload.repository.default_branch; const issueCreatedDay = new Date(context.payload.issue.created_at).getUTCDay(); @@ -96,20 +97,36 @@ jobs: return; } - const message = [ - 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', - ...(isFridayThroughSunday - ? [ - 'Issues submitted Friday through Sunday are not reviewed. If this is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx', - ] - : []), - '', - `Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, - '', - 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.', - '', - `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, - ].join('\n'); + function buildNormalGateMessage() { + return [ + 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', + ...(isFridayThroughSunday + ? [ + 'Issues submitted Friday through Sunday are not reviewed. If this is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx', + ] + : []), + '', + `Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, + '', + 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.', + '', + `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, + ].join('\n'); + } + + function buildRefactorGateMessage() { + return [ + 'This issue was auto-closed. All issues will be closed until 2026-05-17 because the project is undergoing a large refactor.', + '', + `See the \`bigrefactor\` branch: https://github.com/${context.repo.owner}/${context.repo.repo}/tree/bigrefactor`, + '', + 'Issues closed during this period will not be reviewed. The reason is that the refactor will not get done otherwise, because issue triage has been taking about 8 hours per day.', + '', + 'In case of emergency, ask on Discord: https://discord.com/invite/3cU7Bz4UPx', + ].join('\n'); + } + + const message = ISSUE_GATE_MESSAGE_MODE === 'refactor' ? buildRefactorGateMessage() : buildNormalGateMessage(); await github.rest.issues.createComment({ owner: context.repo.owner, From a646639af92264083c8b56afc7353a65bf0a3ff6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 3 May 2026 22:14:52 +0200 Subject: [PATCH 08/12] chore: label bigrefactor issue closures --- .github/workflows/issue-gate.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 4aa603ef..ad4f911c 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -135,12 +135,16 @@ jobs: body: message, }); - if (isFridayThroughSunday) { + const labelsToAdd = []; + if (isFridayThroughSunday) labelsToAdd.push('closed-because-weekend'); + if (ISSUE_GATE_MESSAGE_MODE === 'refactor') labelsToAdd.push('closed-because-bigrefactor'); + + if (labelsToAdd.length > 0) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - labels: ['closed-because-weekend'], + labels: labelsToAdd, }); } From 370fdae6fa23881b044efbab571fb7bf6267ed6e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 3 May 2026 22:51:42 +0200 Subject: [PATCH 09/12] fix(ai): fall back from codex websocket to sse (#4133) --- packages/ai/src/index.ts | 1 + .../src/providers/openai-codex-responses.ts | 205 ++++++++++++++++-- packages/ai/src/types.ts | 2 + packages/ai/src/utils/diagnostics.ts | 45 ++++ .../coding-agent/src/core/agent-session.ts | 4 +- 5 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 packages/ai/src/utils/diagnostics.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 8798b024..f78cd7c6 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -26,6 +26,7 @@ export type { OpenAIResponsesOptions } from "./providers/openai-responses.js"; export * from "./providers/register-builtins.js"; export * from "./stream.js"; export * from "./types.js"; +export * from "./utils/diagnostics.js"; export * from "./utils/event-stream.js"; export * from "./utils/json-parse.js"; export type { diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7c2adef5..a6a60a45 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -32,6 +32,11 @@ import type { StreamOptions, Usage, } from "../types.js"; +import { + appendAssistantMessageDiagnostic, + createAssistantMessageDiagnostic, + formatThrownValue, +} from "../utils/diagnostics.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; @@ -46,6 +51,7 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); +const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; const CODEX_RESPONSE_STATUSES = new Set([ "completed", @@ -166,8 +172,12 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" ); const bodyJson = JSON.stringify(body); const transport = options?.transport || "auto"; + const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId); + if (websocketDisabledForSession) { + recordWebSocketSseFallback(options?.sessionId); + } - if (transport !== "sse") { + if (transport !== "sse" && !websocketDisabledForSession) { let websocketStarted = false; try { await processWebSocketStream( @@ -194,9 +204,25 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" stream.end(); return; } catch (error) { - if (transport === "websocket" || transport === "websocket-cached" || websocketStarted) { + const aborted = options?.signal?.aborted; + if (aborted || isCodexNonTransportError(error)) { throw error; } + appendAssistantMessageDiagnostic( + output, + createAssistantMessageDiagnostic("provider_transport_failure", error, { + configuredTransport: transport, + fallbackTransport: websocketStarted ? undefined : "sse", + eventsEmitted: websocketStarted, + phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start", + requestBytes: new TextEncoder().encode(bodyJson).byteLength, + }), + ); + recordWebSocketFailure(options?.sessionId, error); + if (websocketStarted) { + throw error; + } + recordWebSocketSseFallback(options?.sessionId); } } @@ -434,6 +460,34 @@ async function processStream( }); } +class CodexApiError extends Error { + readonly code?: string; + readonly payload?: Record; + + constructor(message: string, options?: { code?: string; payload?: Record; cause?: unknown }) { + super(message); + this.name = "CodexApiError"; + this.code = options?.code; + this.payload = options?.payload; + this.cause = options?.cause; + } +} + +class CodexProtocolError extends Error { + readonly payload?: unknown; + + constructor(message: string, options?: { payload?: unknown; cause?: unknown }) { + super(message); + this.name = "CodexProtocolError"; + this.payload = options?.payload; + this.cause = options?.cause; + } +} + +function isCodexNonTransportError(error: unknown): boolean { + return error instanceof CodexApiError || error instanceof CodexProtocolError; +} + async function* mapCodexEvents(events: AsyncIterable>): AsyncGenerator { for await (const event of events) { const type = typeof event.type === "string" ? event.type : undefined; @@ -442,12 +496,17 @@ async function* mapCodexEvents(events: AsyncIterable>): if (type === "error") { const code = (event as { code?: string }).code || ""; const message = (event as { message?: string }).message || ""; - throw new Error(`Codex error: ${message || code || JSON.stringify(event)}`); + throw new CodexApiError(`Codex error: ${message || code || JSON.stringify(event)}`, { + code: code || undefined, + payload: event, + }); } if (type === "response.failed") { - const msg = (event as { response?: { error?: { message?: string } } }).response?.error?.message; - throw new Error(msg || "Codex response failed"); + const response = (event as { response?: { error?: { code?: string; message?: string } } }).response; + const code = response?.error?.code; + const message = response?.error?.message; + throw new CodexApiError(message || "Codex response failed", { code, payload: event }); } if (type === "response.done" || type === "response.completed" || type === "response.incomplete") { @@ -498,8 +557,13 @@ async function* parseSSE(response: Response): AsyncGenerator; + } catch (cause) { + throw new CodexProtocolError(`Invalid Codex SSE JSON: ${formatThrownValue(cause)}`, { + cause, + payload: data, + }); + } } } idx = buffer.indexOf("\n\n"); @@ -556,10 +620,15 @@ export interface OpenAICodexWebSocketDebugStats { lastInputItems: number; lastDeltaInputItems?: number; lastPreviousResponseId?: string; + websocketFailures: number; + sseFallbacks: number; + websocketFallbackActive?: boolean; + lastWebSocketError?: string; } const websocketSessionCache = new Map(); const websocketDebugStats = new Map(); +const websocketSseFallbackSessions = new Set(); function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats { let stats = websocketDebugStats.get(sessionId); @@ -573,6 +642,8 @@ function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocket fullContextRequests: 0, deltaRequests: 0, lastInputItems: 0, + websocketFailures: 0, + sseFallbacks: 0, }; websocketDebugStats.set(sessionId, stats); } @@ -587,9 +658,11 @@ export function getOpenAICodexWebSocketDebugStats(sessionId: string): OpenAICode export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void { if (sessionId) { websocketDebugStats.delete(sessionId); + websocketSseFallbackSessions.delete(sessionId); return; } websocketDebugStats.clear(); + websocketSseFallbackSessions.clear(); } export function closeOpenAICodexWebSocketSessions(sessionId?: string): void { @@ -609,6 +682,27 @@ export function closeOpenAICodexWebSocketSessions(sessionId?: string): void { websocketSessionCache.clear(); } +function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean { + return sessionId ? websocketSseFallbackSessions.has(sessionId) : false; +} + +function recordWebSocketSseFallback(sessionId: string | undefined): void { + if (!sessionId) return; + const stats = getOrCreateWebSocketDebugStats(sessionId); + stats.sseFallbacks++; + stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId); +} + +function recordWebSocketFailure(sessionId: string | undefined, error: unknown): void { + if (!sessionId) return; + websocketSseFallbackSessions.add(sessionId); + + const stats = getOrCreateWebSocketDebugStats(sessionId); + stats.websocketFailures++; + stats.lastWebSocketError = formatThrownValue(error); + stats.websocketFallbackActive = true; +} + type WebSocketConstructor = new ( url: string, protocols?: string | string[] | { headers?: Record }, @@ -620,6 +714,20 @@ function getWebSocketConstructor(): WebSocketConstructor | null { return ctor as unknown as WebSocketConstructor; } +class WebSocketCloseError extends Error { + readonly code?: number; + readonly reason?: string; + readonly wasClean?: boolean; + + constructor(message: string, options?: { code?: number; reason?: string; wasClean?: boolean }) { + super(message); + this.name = "WebSocketCloseError"; + this.code = options?.code; + this.reason = options?.reason; + this.wasClean = options?.wasClean; + } +} + function getWebSocketReadyState(socket: WebSocketLike): number | undefined { const readyState = (socket as { readyState?: unknown }).readyState; return typeof readyState === "number" ? readyState : undefined; @@ -798,11 +906,22 @@ async function acquireWebSocket( } function extractWebSocketError(event: unknown): Error { - if (event && typeof event === "object" && "message" in event) { - const message = (event as { message?: unknown }).message; + if (event && typeof event === "object") { + const message = "message" in event ? (event as { message?: unknown }).message : undefined; if (typeof message === "string" && message.length > 0) { return new Error(message); } + + const nestedError = "error" in event ? (event as { error?: unknown }).error : undefined; + if (nestedError instanceof Error && nestedError.message.length > 0) { + return nestedError; + } + if (nestedError && typeof nestedError === "object" && "message" in nestedError) { + const nestedMessage = (nestedError as { message?: unknown }).message; + if (typeof nestedMessage === "string" && nestedMessage.length > 0) { + return new Error(nestedMessage); + } + } } return new Error("WebSocket error"); } @@ -811,9 +930,17 @@ function extractWebSocketCloseError(event: unknown): Error { if (event && typeof event === "object") { const code = "code" in event ? (event as { code?: unknown }).code : undefined; const reason = "reason" in event ? (event as { reason?: unknown }).reason : undefined; + const wasClean = "wasClean" in event ? (event as { wasClean?: unknown }).wasClean : undefined; const codeText = typeof code === "number" ? ` ${code}` : ""; - const reasonText = typeof reason === "string" && reason.length > 0 ? ` ${reason}` : ""; - return new Error(`WebSocket closed${codeText}${reasonText}`.trim()); + let reasonText = typeof reason === "string" && reason.length > 0 ? ` ${reason}` : ""; + if (!reasonText && code === WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE) { + reasonText = " message too big"; + } + return new WebSocketCloseError(`WebSocket closed${codeText}${reasonText}`.trim(), { + code: typeof code === "number" ? code : undefined, + reason: typeof reason === "string" && reason.length > 0 ? reason : undefined, + wasClean: typeof wasClean === "boolean" ? wasClean : undefined, + }); } return new Error("WebSocket closed"); } @@ -851,10 +978,11 @@ async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): Asy const onMessage: WebSocketListener = (event) => { void (async () => { - if (!event || typeof event !== "object" || !("data" in event)) return; - const text = await decodeWebSocketData((event as { data?: unknown }).data); - if (!text) return; + let text: string | null = null; try { + if (!event || typeof event !== "object" || !("data" in event)) return; + text = await decodeWebSocketData((event as { data?: unknown }).data); + if (!text) return; const parsed = JSON.parse(text) as Record; const type = typeof parsed.type === "string" ? parsed.type : ""; if (type === "response.completed" || type === "response.done" || type === "response.incomplete") { @@ -863,7 +991,14 @@ async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): Asy } queue.push(parsed); wake(); - } catch {} + } catch (cause) { + failed = new CodexProtocolError(`Invalid Codex WebSocket JSON: ${formatThrownValue(cause)}`, { + cause, + payload: text, + }); + done = true; + wake(); + } })(); }; @@ -980,6 +1115,23 @@ function buildCachedWebSocketRequestBody(entry: CachedWebSocketConnection, body: }; } +async function* startWebSocketOutputOnFirstEvent( + events: AsyncIterable, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onStart: () => void, +): AsyncGenerator { + let started = false; + for await (const event of events) { + if (!started) { + started = true; + onStart(); + stream.push({ type: "start", partial: output }); + } + yield event; + } +} + async function processWebSocketStream( url: string, body: RequestBody, @@ -1017,13 +1169,22 @@ async function processWebSocketStream( } try { socket.send(JSON.stringify({ type: "response.create", ...requestBody })); - onStart(); - stream.push({ type: "start", partial: output }); - await processResponsesStream(mapCodexEvents(parseWebSocket(socket, options?.signal)), output, stream, model, { - serviceTier: options?.serviceTier, - resolveServiceTier: resolveCodexServiceTier, - applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), - }); + await processResponsesStream( + startWebSocketOutputOnFirstEvent( + mapCodexEvents(parseWebSocket(socket, options?.signal)), + output, + stream, + onStart, + ), + output, + stream, + model, + { + serviceTier: options?.serviceTier, + resolveServiceTier: resolveCodexServiceTier, + applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), + }, + ); if (options?.signal?.aborted) { keepConnection = false; } else if (useCachedContext && entry && output.responseId) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 2ed5da10..e7947756 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,3 +1,4 @@ +import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js"; import type { AssistantMessageEventStream } from "./utils/event-stream.js"; export type { AssistantMessageEventStream } from "./utils/event-stream.js"; @@ -224,6 +225,7 @@ export interface AssistantMessage { model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one + diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; errorMessage?: string; diff --git a/packages/ai/src/utils/diagnostics.ts b/packages/ai/src/utils/diagnostics.ts new file mode 100644 index 00000000..89d13561 --- /dev/null +++ b/packages/ai/src/utils/diagnostics.ts @@ -0,0 +1,45 @@ +export interface DiagnosticErrorInfo { + name?: string; + message: string; + stack?: string; + code?: string | number; +} + +export interface AssistantMessageDiagnostic { + type: string; + timestamp: number; + error?: DiagnosticErrorInfo; + details?: Record; +} + +export function formatThrownValue(value: unknown): string { + if (value instanceof Error) return value.message || value.name; + if (typeof value === "string") return value; + return String(value); +} + +export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo { + if (!(error instanceof Error)) return { name: "ThrownValue", message: formatThrownValue(error) }; + const code = (error as Error & { code?: unknown }).code; + return { + name: error.name || undefined, + message: error.message || error.name, + stack: error.stack, + code: typeof code === "string" || typeof code === "number" ? code : undefined, + }; +} + +export function createAssistantMessageDiagnostic( + type: string, + error: unknown, + details?: Record, +): AssistantMessageDiagnostic { + return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details }; +} + +export function appendAssistantMessageDiagnostic( + message: T, + diagnostic: AssistantMessageDiagnostic, +): void { + message.diagnostics = [...(message.diagnostics ?? []), diagnostic]; +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 15476744..10fe3760 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2417,8 +2417,8 @@ export class AgentSession { if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; - // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), fetch failed, request ended without sending chunks, HTTP/2 closed before response, terminated, retry delay exceeded - return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( + // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, request ended without sending chunks, HTTP/2 closed before response, terminated, retry delay exceeded + return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( err, ); } From 5fa277b320531f43a1378f357bac6143f99e77c6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 3 May 2026 23:25:56 +0200 Subject: [PATCH 10/12] fix(coding-agent): close codex websocket sessions Fixes #4103 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/core/agent-session.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 77c3dea5..c9dbcd3d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,10 @@ - Changed `read` tool rendering to collapse Pi documentation, AGENTS/CLAUDE context files, and `SKILL.md` contents by default in interactive output. +### Fixed + +- Fixed OpenAI Codex WebSocket transport keeping `--print` and JSON mode processes alive after the response by closing cached WebSocket sessions during session shutdown ([#4103](https://github.com/badlogic/pi-mono/issues/4103)). + ## [0.72.1] - 2026-05-02 ## [0.72.0] - 2026-05-01 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 10fe3760..5e93e5f1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -31,6 +31,7 @@ import { modelsAreEqual, resetApiProviders, } from "@mariozechner/pi-ai"; +import { closeOpenAICodexWebSocketSessions } from "@mariozechner/pi-ai/openai-codex-responses"; import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { sleep } from "../utils/sleep.js"; @@ -752,6 +753,7 @@ export class AgentSession { ); this._disconnectFromAgent(); this._eventListeners = []; + closeOpenAICodexWebSocketSessions(this.sessionId); } // ========================================================================= From 23420012abd670e7ff74dd47c8a81103ca97c0a9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 4 May 2026 00:45:56 +0200 Subject: [PATCH 11/12] fix(coding-agent): decouple codex session cleanup --- packages/ai/src/index.ts | 6 +---- .../src/providers/openai-codex-responses.ts | 3 +++ packages/ai/src/session-resources.ts | 24 +++++++++++++++++++ .../coding-agent/src/core/agent-session.ts | 4 ++-- 4 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 packages/ai/src/session-resources.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index f78cd7c6..9f996059 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -16,14 +16,10 @@ export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats, } from "./providers/openai-codex-responses.js"; -export { - closeOpenAICodexWebSocketSessions, - getOpenAICodexWebSocketDebugStats, - resetOpenAICodexWebSocketDebugStats, -} from "./providers/openai-codex-responses.js"; export type { OpenAICompletionsOptions } from "./providers/openai-completions.js"; export type { OpenAIResponsesOptions } from "./providers/openai-responses.js"; export * from "./providers/register-builtins.js"; +export * from "./session-resources.js"; export * from "./stream.js"; export * from "./types.js"; export * from "./utils/diagnostics.js"; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index a6a60a45..80eaf496 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -22,6 +22,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version import { getEnvApiKey } from "../env-api-keys.js"; import { clampThinkingLevel } from "../models.js"; +import { registerSessionResourceCleanup } from "../session-resources.js"; import type { Api, AssistantMessage, @@ -682,6 +683,8 @@ export function closeOpenAICodexWebSocketSessions(sessionId?: string): void { websocketSessionCache.clear(); } +registerSessionResourceCleanup(closeOpenAICodexWebSocketSessions); + function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean { return sessionId ? websocketSseFallbackSessions.has(sessionId) : false; } diff --git a/packages/ai/src/session-resources.ts b/packages/ai/src/session-resources.ts new file mode 100644 index 00000000..d6300926 --- /dev/null +++ b/packages/ai/src/session-resources.ts @@ -0,0 +1,24 @@ +export type SessionResourceCleanup = (sessionId?: string) => void; + +const sessionResourceCleanups = new Set(); + +export function registerSessionResourceCleanup(cleanup: SessionResourceCleanup): () => void { + sessionResourceCleanups.add(cleanup); + return () => { + sessionResourceCleanups.delete(cleanup); + }; +} + +export function cleanupSessionResources(sessionId?: string): void { + const errors: unknown[] = []; + for (const cleanup of sessionResourceCleanups) { + try { + cleanup(sessionId); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to cleanup session resources"); + } +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5e93e5f1..db2d6f0d 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -26,12 +26,12 @@ import type { import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { clampThinkingLevel, + cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@mariozechner/pi-ai"; -import { closeOpenAICodexWebSocketSessions } from "@mariozechner/pi-ai/openai-codex-responses"; import { theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { sleep } from "../utils/sleep.js"; @@ -753,7 +753,7 @@ export class AgentSession { ); this._disconnectFromAgent(); this._eventListeners = []; - closeOpenAICodexWebSocketSessions(this.sessionId); + cleanupSessionResources(this.sessionId); } // ========================================================================= From 324aa1d6477a2c889841a720600983af49a8dc26 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 4 May 2026 00:52:56 +0200 Subject: [PATCH 12/12] fix(coding-agent): render compact read calls directly Render compact read classifications in the call row and leave the collapsed result row empty. The previous implementation used shared renderer state to let renderResult hide renderCall, which leaked an internal ReadRenderState type through the tool definition and coupled two render phases unnecessarily. The call renderer has all context needed to choose the compact presentation itself. --- packages/coding-agent/src/core/tools/read.ts | 37 ++++---------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 08e4f825..432e5254 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -33,20 +33,6 @@ interface CompactReadClassification { label: string; } -interface ReadRenderState { - hideCall?: boolean; -} - -class ReadCallText extends Text { - constructor(private state: ReadRenderState) { - super("", 0, 0); - } - - override render(width: number): string[] { - return this.state.hideCall ? [] : super.render(width); - } -} - const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); /** @@ -154,7 +140,7 @@ function getCompactReadClassification( return undefined; } -function formatCompactReadResult(classification: CompactReadClassification, theme: Theme): string { +function formatCompactReadCall(classification: CompactReadClassification, theme: Theme): string { if (classification.kind === "skill") { return ( theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + @@ -180,11 +166,8 @@ function formatReadResult( cwd: string, isError: boolean, ): string { - if (!options.expanded && !isError) { - const classification = getCompactReadClassification(args, cwd); - if (classification) { - return formatCompactReadResult(classification, theme); - } + if (!options.expanded && !isError && getCompactReadClassification(args, cwd)) { + return ""; } const rawPath = str(args?.file_path ?? args?.path); @@ -216,7 +199,7 @@ function formatReadResult( export function createReadToolDefinition( cwd: string, options?: ReadToolOptions, -): ToolDefinition { +): ToolDefinition { const autoResizeImages = options?.autoResizeImages ?? true; const ops = options?.operations ?? defaultReadOperations; return { @@ -351,22 +334,16 @@ export function createReadToolDefinition( ); }, renderCall(args, theme, context) { - const text = - context.lastComponent instanceof ReadCallText ? context.lastComponent : new ReadCallText(context.state); - context.state.hideCall = false; - text.setText(formatReadCall(args, theme)); + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined; + text.setText(classification ? formatCompactReadCall(classification, theme) : formatReadCall(args, theme)); return text; }, renderResult(result, options, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - const hideCall = - !options.expanded && - !context.isError && - getCompactReadClassification(context.args, context.cwd) !== undefined; text.setText( formatReadResult(context.args, result, options, theme, context.showImages, context.cwd, context.isError), ); - context.state.hideCall = hideCall; return text; }, };