Files
Raúl 37acde15d5 fix(ai): defer Codex model discovery until a Codex session starts (#1145)
* fix(ai): defer Codex model discovery until a Codex session starts

Opening any plan, annotate, or code review builds the shared AI runtime, and the
runtime called every provider's fetchModels() while constructing itself. For the
Codex provider that starts a throwaway `codex app-server` process, so a review
launched Codex even when the user never opened Ask AI. On macOS with a
quarantined Homebrew Codex payload this surfaces as a Gatekeeper confirmation
dialog in front of the review the user actually asked for.

Codex discovery now runs on explicit activation instead. The provider is still
registered and still advertised through /api/ai/capabilities using its static
fallback model metadata, so nothing about discovery is user-visible until a
session is created for it. createBestEffortOnce() memoizes the discovery call so
it runs at most once per runtime and a failure leaves the static fallback in
place rather than blocking session creation.

/api/ai/session gained a beforeProviderSession hook, invoked for the resolved
provider id before the session is created. /api/ai/capabilities deliberately
does not invoke it: the editor probes capabilities automatically on load, so
activating a provider there would reintroduce the same eager launch through a
different path.

Because discovery can replace the provider's model list, the session handler
compares the requested model against the pre-activation default. A caller that
sent no model, or sent the pre-activation default, gets the post-activation
default; an explicitly chosen model is always honored. Without this a first
Codex session would pin the static fallback model that discovery just replaced.

Both runtimes are changed the same way, and the other providers keep their
existing eager discovery, which beforeCapabilities still awaits.

Tests cover the regression with a fake Codex executable rather than a real one:
runtime construction and a capabilities probe must not invoke discovery, the
first Codex session must, the second must not, and a failing discovery must
still create a session on the fallback metadata.

* fix(ai): refresh provider metadata on explicit activation

Follow-up to the deferred Codex discovery change, addressing the review
findings on #1145 while keeping the deferral intact: constructing the
runtime and probing /api/ai/capabilities still never spawns
`codex app-server`.

- /api/ai/capabilities now accepts ?activate=<providerId>: it runs the
  same createBestEffortOnce initializer the session path uses (no second
  discovery path) and responds with the refreshed capabilities payload.
  A plain capabilities probe still activates nothing. Both runtimes get
  this through the shared endpoint (packages/ai is vendored into the Pi
  server by vendor.sh).
- The apps activate the selected provider on explicit user gestures --
  opening the Ask AI surface or switching the provider picker -- via the
  new useAIProviderActivation hook (single-flight per provider id), then
  merge the refreshed models and reasoning efforts into state so the
  model picker and per-model reasoning-effort selector populate past the
  static fallback. (review finding 1)
- A resolver-derived model is no longer persisted: useAIProviderConfig
  and AISettingsTab write the per-provider model preference only on an
  explicit user pick, so a saved Codex model the pre-activation fallback
  list doesn't include survives instead of being clobbered by the
  fallback id. The session request still falls back; the cookie doesn't.
  (review finding 2)
- The session handler resolves the requested model by membership in the
  post-activation model list instead of comparing against the
  pre-activation default, so sessions after the first can no longer pin
  a stale fallback id that discovery already replaced. (review finding 3)

Tests: activation endpoint behavior (shared endpoints plus both runtimes
against a hermetic fake codex on PATH), effectiveModel membership
resolution, and saved-preference no-clobber (DOM tests for
useAIProviderConfig persistence).

Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-29 23:36:47 -07:00

151 lines
5.2 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import {
chmodSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("createAIRuntime Codex discovery", () => {
test("capabilities does not execute Codex and session activation does", async () => {
if (process.platform === "win32") return;
const dir = mkdtempSync(join(tmpdir(), "plannotator-lazy-codex-"));
tempDirs.push(dir);
const marker = join(dir, "codex-ran");
const codex = join(dir, "codex");
writeFileSync(codex, `#!/bin/sh\necho ran > '${marker}'\nexit 1\n`);
chmodSync(codex, 0o755);
const runner = join(dir, "runner.ts");
const runtimeUrl = pathToFileURL(join(import.meta.dir, "ai-runtime.ts")).href;
writeFileSync(runner, `
import { existsSync } from "node:fs";
import { createAIRuntime } from ${JSON.stringify(runtimeUrl)};
const runtime = await createAIRuntime({ cwd: ${JSON.stringify(dir)} });
const capabilities = await runtime.endpoints["/api/ai/capabilities"](
new Request("http://localhost/api/ai/capabilities"),
);
const data = await capabilities.json();
const afterCapabilities = existsSync(${JSON.stringify(marker)});
const session = await runtime.endpoints["/api/ai/session"](
new Request("http://localhost/api/ai/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
context: { mode: "plan-review", plan: { plan: "# Test" } },
providerId: "codex-sdk",
}),
}),
);
console.log(JSON.stringify({
hasCodex: data.providers.some((provider) => provider.id === "codex-sdk"),
afterCapabilities,
sessionStatus: session.status,
afterSession: existsSync(${JSON.stringify(marker)}),
}));
runtime.dispose();
`);
const proc = Bun.spawn([process.execPath, runner], {
cwd: import.meta.dir,
env: { ...process.env, PATH: `${dir}:/usr/bin:/bin` },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode, stderr).toBe(0);
expect(JSON.parse(stdout.trim())).toEqual({
hasCodex: true,
afterCapabilities: false,
sessionStatus: 200,
afterSession: true,
});
}, 15_000);
test("capabilities?activate= runs discovery once and shares it with the session path", async () => {
if (process.platform === "win32") return;
const dir = mkdtempSync(join(tmpdir(), "plannotator-activate-codex-"));
tempDirs.push(dir);
const marker = join(dir, "codex-ran");
const codex = join(dir, "codex");
writeFileSync(codex, `#!/bin/sh\necho ran >> '${marker}'\nexit 1\n`);
chmodSync(codex, 0o755);
const runner = join(dir, "runner.ts");
const runtimeUrl = pathToFileURL(join(import.meta.dir, "ai-runtime.ts")).href;
writeFileSync(runner, `
import { existsSync, readFileSync } from "node:fs";
import { createAIRuntime } from ${JSON.stringify(runtimeUrl)};
const runs = () => existsSync(${JSON.stringify(marker)})
? readFileSync(${JSON.stringify(marker)}, "utf8").trim().split("\\n").length
: 0;
const runtime = await createAIRuntime({ cwd: ${JSON.stringify(dir)} });
const probe = await runtime.endpoints["/api/ai/capabilities"](
new Request("http://localhost/api/ai/capabilities"),
);
const runsAfterProbe = runs();
const activate = await runtime.endpoints["/api/ai/capabilities"](
new Request("http://localhost/api/ai/capabilities?activate=codex-sdk"),
);
const runsAfterActivate = runs();
const session = await runtime.endpoints["/api/ai/session"](
new Request("http://localhost/api/ai/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
context: { mode: "plan-review", plan: { plan: "# Test" } },
providerId: "codex-sdk",
}),
}),
);
console.log(JSON.stringify({
probeStatus: probe.status,
runsAfterProbe,
activateStatus: activate.status,
runsAfterActivate,
sessionStatus: session.status,
runsAfterSession: runs(),
}));
runtime.dispose();
`);
const proc = Bun.spawn([process.execPath, runner], {
cwd: import.meta.dir,
env: { ...process.env, PATH: `${dir}:/usr/bin:/bin` },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode, stderr).toBe(0);
expect(JSON.parse(stdout.trim())).toEqual({
probeStatus: 200,
runsAfterProbe: 0,
activateStatus: 200,
runsAfterActivate: 1,
sessionStatus: 200,
runsAfterSession: 1,
});
}, 15_000);
});