diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 70551aa9d0..33efc11894 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,38 @@ # changes +## 2026-09-01 - Fail closed on an unusable model budget at startup + +### What changed + +- `packages/coding-agent/src/main.ts` wraps the `createAgentSessionRuntime` call with a try/catch that + specifically catches `ModelUsabilityBudgetError`, printing the error's own actionable message + (`chalk.red`) and exiting with `process.exit(1)` instead of letting the error escape uncaught. + +### Why + +- `AgentSession.assertModelUsable` (via `createAgentSession` in `sdk.ts`) throws + `ModelUsabilityBudgetError` when the selected model's context window cannot hold the assembled + session budget (live context + system prompt + tool schemas + output/compaction/speculation + reserves + safety margin) — most commonly hit when resuming a large existing session with a model + whose context window is too small. Nothing between that throw site and the top-level `await main()` + call in `cli-main.ts` ever catches it, so the error reaches Node as an uncaught exception: a raw + stack trace plus the `Node.js vX.Y.Z` crash banner, and the process exits without ever reaching the + point where the TUI, RPC transport, or any other surface could render a normal error state. The + thrown error's own message is already fully actionable (it names the exact shortfall and suggests + compacting the session and retrying), so the fix only needed to stop it from being silently dropped + before that message could reach the user. + +### Why an extension could not handle it + +- The throw happens inside `createAgentSessionRuntime`, which runs before any extension host exists; + by the time an extension could observe the failure, the process has already crashed. + +### Expected merge conflict zones + +- LOW: the `createAgentSessionRuntime` call site and its surrounding `time("createAgentSessionRuntime")` + call in `main.ts`; this block has changed at least once before (see the 2026-08-30 entry above) so a + future upstream sync may touch the same lines again. + ## 2026-09-01 - Negotiate RPC session auto-titling ### What changed diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 86e791f72c..c48a8846d9 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -53,6 +53,7 @@ import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; import { envValue } from "./core/brand.ts"; import { type CredentialAccountSummary, summarizeCredentialAccounts } from "./core/credential-accounts.ts"; import { exportFromFile } from "./core/export-html/index.ts"; +import { ModelUsabilityBudgetError } from "./core/extensions/builtin/compaction/model-usability-budget.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { @@ -1113,13 +1114,20 @@ export async function main(args: string[], options?: MainOptions) { listen: parsed.listen, }); } - const runtime = await createAgentSessionRuntime(createRuntime, { - cwd: sessionManager.getCwd(), - agentDir, - sessionManager, - }).finally(() => { - startupLoadingIndicator.stop(); - }); + let runtime: Awaited>; + try { + runtime = await createAgentSessionRuntime(createRuntime, { + cwd: sessionManager.getCwd(), + agentDir, + sessionManager, + }).finally(() => { + startupLoadingIndicator.stop(); + }); + } catch (error) { + if (!(error instanceof ModelUsabilityBudgetError)) throw error; + console.error(chalk.red(`Error: ${error.message}`)); + process.exit(1); + } time("createAgentSessionRuntime"); let selectedRuntime = runtime; if (isTruthyEnvFlag(envValue("DISABLE_SHARED_HOST"))) { diff --git a/packages/coding-agent/test/suite/regressions/model-usability-budget-startup-crash.test.ts b/packages/coding-agent/test/suite/regressions/model-usability-budget-startup-crash.test.ts new file mode 100644 index 0000000000..845989d5d2 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/model-usability-budget-startup-crash.test.ts @@ -0,0 +1,223 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../../../src/config.ts"; +import { assertWorkspaceBuildPrerequisite } from "../../support/workspace-build-prerequisite.ts"; + +assertWorkspaceBuildPrerequisite(import.meta.url); + +/** + * Regression: an unusable model budget at session-creation time must never + * escape as an uncaught exception. + * + * `AgentSession.assertModelUsable` throws `ModelUsabilityBudgetError` when + * the selected model's context window cannot hold the assembled session + * budget (live context + system prompt + tool schemas + reserves + safety + * margin) - the common trigger is resuming a large existing session with a + * model whose context window is too small for it. Before this fix, nothing + * between that throw site (`sdk.ts`, via `createAgentSessionRuntime` in + * `main.ts`) and the top-level `await main()` call caught it, so the error + * reached Node as an uncaught exception: a raw stack trace plus the + * `Node.js vX.Y.Z` crash banner, and the process died before any surface + * (TUI, RPC, print mode) could render a normal error state. + */ + +const cliPath = resolve(__dirname, "../../../src/cli.ts"); +const SESSION_ID = "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d4"; +const TINY_MODEL_CONTEXT_WINDOW = 16_000; +const CHILD_TIMEOUT_MS = 20_000; +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); + +interface CliFixture { + agentDir: string; + projectDir: string; + sessionDir: string; +} + +interface CliResult { + code: number | null; + output: string; + timedOut: boolean; +} + +const tempDirs: string[] = []; +const liveChildren = new Set(); + +function killChild(child: ChildProcess): void { + if (child.exitCode !== null || child.signalCode !== null) return; + const pid = child.pid; + if (pid === undefined) return; + try { + child.kill("SIGKILL"); + } catch { + // Already gone. + } +} + +afterEach(() => { + for (const child of liveChildren) killChild(child); + liveChildren.clear(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createFixture(): CliFixture { + // realpath: on macOS tmpdir() is a symlink (/var -> /private/var) while the + // spawned CLI sees the physical path via process.cwd(). Session cwd filtering + // compares paths textually, so the fixture must use physical paths. + const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "pi-model-usability-budget-crash-"))); + tempDirs.push(tempRoot); + const fixture: CliFixture = { + agentDir: join(tempRoot, "agent"), + projectDir: join(tempRoot, "project"), + sessionDir: join(tempRoot, "sessions"), + }; + for (const dir of [fixture.agentDir, fixture.projectDir, fixture.sessionDir]) { + mkdirSync(dir, { recursive: true }); + } + + // A custom provider whose only model has a context window far smaller than + // what the fixture session below will require, so session creation rejects + // the budget instead of silently accepting it. + writeFileSync( + join(fixture.agentDir, "models.json"), + `${JSON.stringify({ + providers: { + "faux-tiny": { + baseUrl: "https://example.test/v1", + api: "openai-completions", + apiKey: "test-key", + models: [{ id: "tiny-ctx", contextWindow: TINY_MODEL_CONTEXT_WINDOW, maxTokens: 4_000 }], + }, + }, + })}\n`.replace("TINY_MODEL_CONTEXT_WINDOW", String(TINY_MODEL_CONTEXT_WINDOW)), + ); + + // A persisted session whose live context alone (~40k tokens of prior usage) + // already exceeds the tiny model's 16k context window once the fixed system + // prompt / tool schema / reserve overhead is added on top. + const sessionLines = [ + JSON.stringify({ + type: "session", + version: 3, + id: SESSION_ID, + timestamp: "2026-08-07T00:00:00.000Z", + cwd: fixture.projectDir, + }), + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-08-07T00:00:01.000Z", + message: { role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 }, + }), + JSON.stringify({ + type: "message", + id: "m2", + parentId: "m1", + timestamp: "2026-08-07T00:00:02.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "hi there" }], + api: "openai-completions", + provider: "faux-tiny", + model: "tiny-ctx", + stopReason: "stop", + usage: { + input: 40_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 41_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 2, + }, + }), + ]; + writeFileSync(join(fixture.sessionDir, `${SESSION_ID}.jsonl`), `${sessionLines.join("\n")}\n`); + + return fixture; +} + +function stripAnsi(value: string): string { + return value.replace(ANSI_PATTERN, ""); +} + +async function runCli(args: string[], fixture: CliFixture): Promise { + const child = spawn(process.execPath, args, { + cwd: fixture.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: fixture.agentDir, + PI_OFFLINE: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + liveChildren.add(child); + + let captured = ""; + child.stdout?.on("data", (chunk: Buffer) => { + captured += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + captured += chunk.toString(); + }); + + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + killChild(child); + }, CHILD_TIMEOUT_MS); + + try { + const code = await new Promise((resolveExit, rejectSpawn) => { + child.on("error", rejectSpawn); + child.on("close", (exitCode) => resolveExit(exitCode)); + }); + return { code, output: stripAnsi(captured), timedOut }; + } finally { + clearTimeout(timeout); + killChild(child); + liveChildren.delete(child); + } +} + +describe("model usability budget rejection at startup", () => { + it("fails closed with the budget error message instead of an uncaught crash", async () => { + const fixture = createFixture(); + + const result = await runCli( + [ + cliPath, + "--session-dir", + fixture.sessionDir, + "--session", + SESSION_ID, + "--provider", + "faux-tiny", + "--model", + "tiny-ctx", + "-p", + "continue", + ], + fixture, + ); + + expect(result.timedOut).toBe(false); + // The error is a clean, handled exit(1) - not the default 1 Node also uses + // for an uncaught exception - so this alone would not distinguish the two; + // the crash-signature assertions below are what pin the actual regression. + expect(result.code).toBe(1); + expect(result.output).toContain("cannot switch: target context window 16000 tokens"); + expect(result.output).toContain("Compact the session, then revalidate and retry the model switch."); + // Before the fix, the error escaped as an uncaught exception: this asserts + // none of that crash signature is present in the handled output. + expect(result.output).not.toContain("Node.js v"); + expect(result.output).not.toContain("at AgentSession.assertModelUsable"); + expect(result.output).not.toContain("at createAgentSession"); + }); +});