diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2a884888a..87ab7b685 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- Changed Kimi Coding requests to use pi's runtime `User-Agent` header. - Automatically converted supported strict tool schemas to provider-compatible closed objects with required nullable optional fields while preserving original tool definitions, and treated `null` values for optional non-nullable tool arguments as omitted. - Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d44c75e97..e36d5551b 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -148,10 +148,6 @@ const COPILOT_STATIC_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; -const KIMI_STATIC_HEADERS = { - "User-Agent": "KimiCLI/1.5", -} as const; - const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -2057,7 +2053,6 @@ async function loadModelsDevData(): Promise[]> { provider: "kimi-coding", // Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages baseUrl: "https://api.kimi.com/coding", - headers: { ...KIMI_STATIC_HEADERS }, compat: { ...(allowEmptySignature ? { allowEmptySignature: true } : {}), forceAdaptiveThinking: true, diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 10f91f2a3..b9586120d 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -33,6 +33,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -271,6 +272,20 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid return merged; } +function mergeClientHeaders( + model: Model<"anthropic-messages">, + ...headerSources: (ProviderHeaders | undefined)[] +): ProviderHeaders { + const merged = mergeHeaders(...headerSources); + if (model.provider === "kimi-coding") { + for (const name of Object.keys(merged)) { + if (name.toLowerCase() === "user-agent") delete merged[name]; + } + merged["User-Agent"] = getPiUserAgent(); + } + return merged; +} + function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { if (!headers) return false; const expected = name.toLowerCase(); @@ -872,7 +887,8 @@ function createClient( baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, - defaultHeaders: mergeHeaders( + defaultHeaders: mergeClientHeaders( + model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -895,7 +911,8 @@ function createClient( baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, - defaultHeaders: mergeHeaders( + defaultHeaders: mergeClientHeaders( + model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -914,7 +931,8 @@ function createClient( // API key or header-owned auth. const sessionAffinityHeaders: ProviderHeaders = sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; - const defaultHeaders = mergeHeaders( + const defaultHeaders = mergeClientHeaders( + model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 863e2ef46..71e0ee9bc 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -1,4 +1,3 @@ -import type * as NodeOs from "node:os"; import type * as NodeZlib from "node:zlib"; import type { Tool as OpenAITool, @@ -7,20 +6,6 @@ import type { ResponseStreamEvent, } from "openai/resources/responses/responses.js"; -type ProcessWithOsBuiltinModule = typeof process & { - getBuiltinModule?: (id: "node:os") => typeof NodeOs; -}; - -function loadNodeOs(): typeof NodeOs | null { - if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { - return null; - } - return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; -} - -// NEVER convert to top-level runtime imports - breaks browser/Vite builds -const _os: typeof NodeOs | null = loadNodeOs(); - import { clampThinkingLevel } from "../models.ts"; import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { @@ -46,6 +31,7 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { uuidv7 } from "../utils/uuid.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; @@ -1618,8 +1604,7 @@ function buildBaseCodexHeaders( headers.set("Authorization", `Bearer ${token}`); headers.set("chatgpt-account-id", accountId); headers.set("originator", "pi"); - const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)"; - headers.set("User-Agent", userAgent); + headers.set("User-Agent", getPiUserAgent()); return headers; } diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts new file mode 100644 index 000000000..93b23dd35 --- /dev/null +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -0,0 +1,19 @@ +import type * as NodeOs from "node:os"; + +type ProcessWithOsBuiltinModule = typeof process & { + getBuiltinModule?: (id: "node:os") => typeof NodeOs; +}; + +function loadNodeOs(): typeof NodeOs | null { + if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { + return null; + } + return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; +} + +// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds. +const nodeOs = loadNodeOs(); + +export function getPiUserAgent(): string { + return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; +} diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index e2780a175..a80a021db 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts"; @@ -71,6 +72,14 @@ const anthropicModel: Model<"anthropic-messages"> = { maxTokens: 4096, }; +const kimiCodingModel: Model<"anthropic-messages"> = { + ...anthropicModel, + id: "kimi-for-coding", + name: "Kimi For Coding", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", +}; + afterEach(() => { mockState.constructorOpts = undefined; mockState.createParams = undefined; @@ -185,3 +194,23 @@ describe("Anthropic auth token env", () => { expect(headers.Authorization).toBe("Bearer explicit-token"); }); }); + +describe("Anthropic-compatible user agents", () => { + it("enforces the pi runtime user agent for Kimi Coding", async () => { + await streamAnthropic(kimiCodingModel, context, { + apiKey: "kimi-key", + headers: { "user-agent": "custom-client" }, + }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + const userAgentHeaders = Object.entries(headers).filter(([name]) => name.toLowerCase() === "user-agent"); + expect(userAgentHeaders).toEqual([["User-Agent", `pi (${platform()} ${release()}; ${arch()})`]]); + }); + + it("does not apply the pi runtime user agent to Anthropic", async () => { + await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + expect(Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")).toBe(false); + }); +}); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index ccd7f51c6..1ef93f3be 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { arch, platform, release, tmpdir } from "node:os"; import { join } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { Type } from "typebox"; @@ -160,6 +160,7 @@ describe("openai-codex streaming", () => { expect(headers?.get("chatgpt-account-id")).toBe("acc_test"); expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental"); expect(headers?.get("originator")).toBe("pi"); + expect(headers?.get("User-Agent")).toBe(`pi (${platform()} ${release()}; ${arch()})`); expect(headers?.get("accept")).toBe("text/event-stream"); expect(headers?.has("x-api-key")).toBe(false); return new Response(stream, { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a008fe693..5d16436f4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,11 +12,13 @@ ### Changed +- Changed inherited Kimi Coding requests to use pi's runtime `User-Agent` header. - Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. - Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). ### Fixed +- Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it. - Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh. - Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index cb48caee9..585160806 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -192,6 +192,20 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic | `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `"shopt -s expand_aliases"`) | | `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `["mise", "exec", "node@20", "--", "npm"]`) | +Windows paths in JSON must use forward slashes or escaped backslashes: + +```json +{ + "shellPath": "C:/Program Files/Git/bin/bash.exe" +} +``` + +```json +{ + "shellPath": "C:\\Program Files\\Git\\bin\\bash.exe" +} +``` + ```json { "npmCommand": ["mise", "exec", "node@20", "--", "npm"] diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index a14f327e8..f3228ef77 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -222,7 +222,7 @@ export function createFindToolDefinition( } // Default implementation uses fd. - const fdPath = await ensureTool("fd", true); + const fdPath = await ensureTool("fd"); if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 2274e7064..a65cab9da 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -174,7 +174,7 @@ export function createGrepToolDefinition( (async () => { try { - const rgPath = await ensureTool("rg", true); + const rgPath = await ensureTool("rg"); if (!rgPath) { settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded"))); return; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5b5b3d86a..4e64341b2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -107,7 +107,7 @@ import { openBrowser } from "../../utils/open-browser.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; -import { ensureTool } from "../../utils/tools-manager.ts"; +import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; import { ArminComponent } from "./components/armin.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; @@ -436,6 +436,7 @@ export class InteractiveMode { // Status line tracking (for mutating immediately-sequential status updates) private lastStatusSpacer: Spacer | undefined = undefined; private lastStatusText: Text | undefined = undefined; + private managedToolStatusStarted = false; // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; @@ -851,11 +852,6 @@ export class InteractiveMode { // Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); - // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) - // Both are needed: fd for autocomplete, rg for grep tool and bash commands - const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); - this.fdPath = fdPath; - if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { const modelList = this.session.scopedModels .map((sm) => { @@ -901,11 +897,12 @@ export class InteractiveMode { this.widgetContainerBelow, this.footerContainer, ]); + // Accept text while startup completes, but only enable interrupt, exit, and submission feedback. + this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); + this.defaultEditor.onCtrlD = () => this.handleCtrlD(); + this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text); this.ui.setFocus(this.editor); - this.setupKeyHandlers(); - this.setupEditorSubmitHandler(); - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs this.ui.start(); this.isInitialized = true; @@ -974,6 +971,20 @@ export class InteractiveMode { } this.ui.requestRender(); + // Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir) + // so slow downloads do not make startup appear frozen. + // Both are needed: fd for autocomplete, rg for grep tool and bash commands. + const [fdPath] = await Promise.all([ + ensureTool("fd", (status) => this.showManagedToolStatus(status)), + ensureTool("rg", (status) => this.showManagedToolStatus(status)), + ]); + this.fdPath = fdPath; + + // Enable the remaining input handlers only after managed-tool setup completes. + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + this.ui.requestRender(); + // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); @@ -2873,6 +2884,11 @@ export class InteractiveMode { } } + private handleStartupSubmit(text: string): void { + this.editor.setText(text); + this.showStatus("Startup is still in progress"); + } + private setupEditorSubmitHandler(): void { this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); @@ -3408,6 +3424,20 @@ export class InteractiveMode { return textBlocks.map((c) => (c as { text: string }).text).join(""); } + /** Show a managed-tool status update in the chat. */ + private showManagedToolStatus(status: ToolStatus): void { + if (!this.managedToolStatusStarted) { + this.chatContainer.addChild(new Spacer(1)); + this.managedToolStatusStarted = true; + } + const message = status.type === "warning" ? `Warning: ${status.message}` : status.message; + const color = status.type === "warning" ? "warning" : "dim"; + this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0)); + this.lastStatusSpacer = undefined; + this.lastStatusText = undefined; + this.ui.requestRender(); + } + /** * Show a status message in the chat. * diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index a177b4f64..23a71b5a1 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import { type SpawnSyncReturns, spawnSync } from "child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; @@ -323,9 +322,20 @@ const TERMUX_PACKAGES: Record = { rg: "ripgrep", }; -// Ensure a tool is available, downloading if necessary -// Returns the path to the tool, or null if unavailable -export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { +export interface ToolStatus { + type: "info" | "warning"; + message: string; +} + +/** + * Ensure a tool is available, downloading if necessary. + * Reports progress through `onStatus`; status messages are otherwise silent. + * Returns the tool path, or undefined if unavailable. + */ +export async function ensureTool( + tool: "fd" | "rg", + onStatus?: (status: ToolStatus) => void, +): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; @@ -335,9 +345,7 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr if (!config) return undefined; if (isOfflineModeEnabled()) { - if (!silent) { - console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`)); - } + onStatus?.({ type: "warning", message: `${config.name} not found. Offline mode enabled, skipping download.` }); return undefined; } @@ -345,27 +353,22 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr // Users must install via pkg. if (platform() === "android") { const pkgName = TERMUX_PACKAGES[tool] ?? tool; - if (!silent) { - console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`)); - } + onStatus?.({ type: "warning", message: `${config.name} not found. Install with: pkg install ${pkgName}` }); return undefined; } // Tool not found - download it - if (!silent) { - console.log(chalk.dim(`${config.name} not found. Downloading...`)); - } + onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` }); try { const path = await downloadTool(tool); - if (!silent) { - console.log(chalk.dim(`${config.name} installed to ${path}`)); - } + onStatus?.({ type: "info", message: `${config.name} installed to ${path}` }); return path; } catch (e) { - if (!silent) { - console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); - } + onStatus?.({ + type: "warning", + message: `Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`, + }); return undefined; } } diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts index b784f3769..28a0f48b8 100644 --- a/packages/coding-agent/test/interactive-mode-startup-input.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -23,7 +23,13 @@ type InputContext = { pendingUserInputs: string[]; }; +type StartupSubmitContext = { + editor: { setText: (text: string) => void }; + showStatus: (message: string) => void; +}; + type InteractiveModePrivate = { + handleStartupSubmit(this: StartupSubmitContext, text: string): void; setupEditorSubmitHandler(this: SubmitContext): void; getUserInput(this: InputContext): Promise; }; @@ -49,6 +55,18 @@ function createSubmitContext(): SubmitContext { } describe("InteractiveMode startup input", () => { + it("restores a prompt submitted while managed-tool setup is running", () => { + const context: StartupSubmitContext = { + editor: { setText: vi.fn() }, + showStatus: vi.fn(), + }; + + interactiveModePrototype.handleStartupSubmit.call(context, "early prompt"); + + expect(context.editor.setText).toHaveBeenCalledWith("early prompt"); + expect(context.showStatus).toHaveBeenCalledWith("Startup is still in progress"); + }); + it("queues a normal prompt submitted before the input callback is installed", async () => { const context = createSubmitContext(); interactiveModePrototype.setupEditorSubmitHandler.call(context); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d422d8f34..a4aaa48c0 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -118,6 +118,30 @@ describe("InteractiveMode.showStatus", () => { }); }); +describe("InteractiveMode.showManagedToolStatus", () => { + beforeAll(() => initTheme("dark")); + + test("renders tool updates as one contiguous group", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + managedToolStatusStarted: false, + lastStatusSpacer: undefined, + lastStatusText: undefined, + }; + const showManagedToolStatus = (InteractiveMode as any).prototype.showManagedToolStatus; + + showManagedToolStatus.call(fakeThis, { type: "info", message: "fd downloading" }); + showManagedToolStatus.call(fakeThis, { type: "info", message: "rg downloading" }); + showManagedToolStatus.call(fakeThis, { type: "warning", message: "rg failed" }); + + expect(fakeThis.chatContainer.children).toHaveLength(4); + expect(normalizeRenderedOutput(fakeThis.chatContainer)).toBe( + "fd downloading\n rg downloading\n Warning: rg failed", + ); + }); +}); + describe("InteractiveMode.setToolsExpanded", () => { test("applies expansion state to the active header and chat entries", () => { const header = { setExpanded: vi.fn() }; diff --git a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts index 8a0afcabf..6e9c45185 100644 --- a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts @@ -59,7 +59,7 @@ async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime; describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint through ModelRuntime streaming", async () => { const { modelRuntime } = await createCloudflareRuntime(); - const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5"); + const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); expect(model).toBeDefined(); resetApiProviders(); @@ -75,7 +75,7 @@ describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint after extension-style auth resolution", async () => { const { modelRegistry } = await createCloudflareRuntime(); - const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5"); + const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); expect(model).toBeDefined(); resetApiProviders(); diff --git a/packages/coding-agent/test/tools-manager.test.ts b/packages/coding-agent/test/tools-manager.test.ts new file mode 100644 index 000000000..fb53baad4 --- /dev/null +++ b/packages/coding-agent/test/tools-manager.test.ts @@ -0,0 +1,47 @@ +import type * as ChildProcess from "node:child_process"; +import type * as Fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureTool, type ToolStatus } from "../src/utils/tools-manager.ts"; + +const originalOffline = process.env.PI_OFFLINE; + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + }; +}); + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: vi.fn(() => ({ error: new Error("not found") })), + }; +}); + +afterEach(() => { + if (originalOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = originalOffline; +}); + +describe("ensureTool", () => { + it("reports status through a callback without writing to the console", async () => { + process.env.PI_OFFLINE = "1"; + const statuses: ToolStatus[] = []; + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + + const result = await ensureTool("fd", (status) => statuses.push(status)); + + expect(result).toBeUndefined(); + expect(statuses).toEqual([ + { + type: "warning", + message: "fd not found. Offline mode enabled, skipping download.", + }, + ]); + expect(consoleLog).not.toHaveBeenCalled(); + consoleLog.mockRestore(); + }); +});