Merge native Claude Code integration with explicit opt-in

# Conflicts:
#	src/agent/teacher-agent.ts
#	src/server/keys.ts
This commit is contained in:
amosblomqvist
2026-10-28 21:45:50 +02:00
64 changed files with 5655 additions and 102 deletions
+4 -2
View File
@@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep
assert.doesNotMatch(html, />Model providers<\/button>/);
});
test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => {
globalThis.fetch = async (url) => {
if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" });
assert.equal(url, "/api/keys");
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
};
fixture(); await mount(<KeysSection />);
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]);
assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]'));
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
+70
View File
@@ -0,0 +1,70 @@
/**
* Claude Code binary lookup (src/agent/claude-code/binary.ts).
*
* The packaged desktop app often starts with a minimal PATH, so lookup falls
* back to the standard install locations. A fake isExecutable set stands in
* for the filesystem.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
const HOME = "/home/u";
function resolveWith(executables: string[], env: Record<string, string> = {}, platform: NodeJS.Platform = "linux", homeDir = HOME) {
const found = new Set(executables);
return resolveClaudeBinary({ env, platform, homeDir, isExecutable: (p) => found.has(p) });
}
test("prefers PATH hit", () => {
const binary = resolveWith(["/usr/bin/claude", `${HOME}/.local/bin/claude`], { PATH: "/opt/x:/usr/bin" });
assert.equal(binary, "/usr/bin/claude");
});
test("falls back to ~/.local/bin when PATH misses", () => {
const binary = resolveWith([`${HOME}/.local/bin/claude`, "/usr/local/bin/claude"], { PATH: "/usr/bin" });
assert.equal(binary, `${HOME}/.local/bin/claude`);
});
test("checks ~/.claude/local, npm global bin, /opt/homebrew/bin, /usr/local/bin in that order", () => {
const all = [`${HOME}/.claude/local/claude`, `${HOME}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"];
for (let i = 0; i < all.length; i++) {
assert.equal(resolveWith(all.slice(i), { PATH: "" }), all[i]);
}
});
test("npm global bin follows npm_config_prefix when set", () => {
const binary = resolveWith(["/opt/npm/bin/claude", `${HOME}/.npm-global/bin/claude`], { npm_config_prefix: "/opt/npm" });
assert.equal(binary, "/opt/npm/bin/claude");
});
test("windows finds native claude.exe and does not advertise unsupported .cmd-only installs", () => {
const winHome = "C:\\Users\\u";
const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), null);
assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe");
assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
assert.equal(
resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome),
null,
);
assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null);
});
test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spawn without a shell", () => {
const winHome = "C:\\Users\\u";
const env = { PATH: "C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
const executables = ["C:\\bin\\claude.cmd", `${winHome}\\.local\\bin\\claude.exe`];
assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
});
test("relative Windows PATH entries are fixed to an absolute discovery identity", () => {
assert.equal(resolveClaudeBinary({ env: { PATH: ".\\bin" }, platform: "win32", homeDir: "C:\\Users\\u", cwd: "C:\\server",
isExecutable: (candidate) => candidate === "C:\\server\\bin\\claude.exe",
}), "C:\\server\\bin\\claude.exe");
});
test("returns null when every location misses", () => {
assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null);
});
+46
View File
@@ -0,0 +1,46 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { StatsBar } from "../components/stats-bar.tsx";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { toModelDto } from "../src/server/model-catalog.ts";
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined;
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; globalThis.fetch = originalFetch; });
test("compact CLI context is numeric only; effective-window explanation appears only in expanded session metadata", async () => {
const catalog: ModelCatalogResponse = { models: CLAUDE_CODE_MODELS.map(toModelDto), providers: [],
slots: { strong: null, cheap: null, vision: null }, defaults: null,
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null } };
globalThis.fetch = (async (url) => {
if (url === "/api/models") return Response.json(catalog);
if (url === "/api/settings") return Response.json({ settings: { models: {} } });
if (url === "/api/chats/cli-session") return Response.json({ settings: { model: { provider: "claude-code", id: "opus" } } });
throw new Error(`unexpected request ${url}`);
}) as typeof fetch;
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] };
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[turn]} contextTokens={42000} cliContextWindow={window} />);
await act(async () => render({ model: "claude-code:opus", tokens: 200000 }));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
assert(!document.body.textContent?.includes("auto-compaction"));
assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI");
const summary = document.querySelector<HTMLButtonElement>('[data-testid="stats-summary"]')!;
assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget"));
await act(async () => summary.click());
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./);
assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI");
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
await act(async () => render(null));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k");
await act(async () => render({ model: "claude-code:sonnet", tokens: 1000000 }));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k", "another alias cannot supply this session's denominator");
});
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ContextWindowProbe } from "../src/agent/claude-code/context-window.ts";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { asAgentMessages, cliContextWindowOf, toLlmMessages, type LhMessage } from "../src/core/native-messages.ts";
import { catalogLimits } from "../components/model-picker.tsx";
import { contextLimitForModel, formatContextMeasurement } from "../components/stats-bar.tsx";
import { toModelDto } from "../src/server/model-catalog.ts";
function setup() {
const writes: { type: string; request_id: string; request: Record<string, string> }[] = [];
const windows: number[] = [];
let prompts = 0;
const probe = new ContextWindowProbe((line) => writes.push(JSON.parse(line)), () => prompts++, (tokens) => windows.push(tokens));
probe.start();
const reply = (index: number, body: unknown, subtype = "success") => probe.handle({ type: "control_response", response: {
request_id: writes[index]!.request_id, subtype, response: body,
} });
return { probe, writes, windows, reply, prompts: () => prompts };
}
test("control initialization precedes a summary-only query; neither needs user input or a model request", () => {
const p = setup();
assert.deepEqual(p.writes[0]!.request, { subtype: "initialize" }); assert.equal(p.prompts(), 0);
p.reply(0, {});
assert.deepEqual(p.writes[1]!.request, { subtype: "get_context_usage", detail: "summary" });
assert.equal(p.prompts(), 1, "conversation does not wait for metadata");
assert.notEqual(p.writes[0]!.request_id, p.writes[1]!.request_id);
p.probe.handle({ type: "control_response", response: { request_id: "foreign", subtype: "success", response: { maxTokens: 999 } } });
assert.deepEqual(p.windows, []);
p.reply(1, { maxTokens: 200000, rawMaxTokens: 1000000 });
assert.deepEqual(p.windows, [200000], "effective maxTokens, not an assumed nominal window");
p.reply(0, {}); p.reply(1, { maxTokens: 1 });
assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, [200000]);
p.probe.close();
});
test("metadata unavailable before first response can be queried again locally; stale/closed replies are ignored", () => {
const p = setup(); p.reply(0, {}); p.reply(1, {}, "error");
assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, []);
p.probe.refresh();
assert.deepEqual(p.writes[2]!.request, { subtype: "get_context_usage", detail: "summary" });
p.reply(1, { maxTokens: 1 }); assert.deepEqual(p.windows, []);
p.reply(2, { maxTokens: 200000 }); assert.deepEqual(p.windows, [200000]);
p.probe.refresh(); p.probe.close(); p.reply(3, { maxTokens: 999999 });
assert.deepEqual(p.windows, [200000]); assert.equal(p.prompts(), 1);
});
for (const tokens of [null, undefined, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, "200000"]) test(`invalid summary budget ${tokens} remains unknown without failing the prompt`, () => {
const p = setup(); p.reply(0, {}); p.reply(1, { maxTokens: tokens });
assert.deepEqual(p.windows, []); assert.equal(p.prompts(), 1); p.probe.close();
});
test("initialization errors/timeouts fall back to ordinary prompt delivery; close cancels the timer", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const error = setup(); error.reply(0, undefined, "error");
assert.equal(error.prompts(), 1); assert.equal(error.writes.length, 1); error.probe.close();
const timeout = setup(); t.mock.timers.tick(5000);
assert.equal(timeout.prompts(), 1); assert.equal(timeout.writes.length, 1);
timeout.reply(0, {}); assert.equal(timeout.writes.length, 1); timeout.probe.close();
const closed = setup(); closed.probe.close(); t.mock.timers.tick(5000);
assert.equal(closed.prompts(), 0);
});
function assistant(model = "opus", tokens: number | undefined = 200000): LhMessage & { role: "assistant" } {
return { id: "cli-assistant", role: "assistant", api: "claude-code-cli", provider: "claude-code", model,
content: [], timestamp: 0, stopReason: "stop", cliContextWindow: tokens,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
test("per-session metadata survives JSON persistence, never borrows older reports, and cannot override other model selections", () => {
const first = assistant();
const window = cliContextWindowOf(JSON.parse(JSON.stringify([first])));
assert.deepEqual(window, { model: "claude-code:opus", tokens: 200000 });
assert(!("cliContextWindow" in toLlmMessages(asAgentMessages([first]))[0]!));
assert.equal(first.cliContextWindow, 200000, "provider projection never mutates the stored report");
const catalog = new Map([["anthropic:fixture", 100000], ["claude-code:opus", 1000000]]);
assert.equal(contextLimitForModel("claude-code:opus", catalog, window), 200000);
assert.equal(contextLimitForModel("claude-code:sonnet", catalog, window), null);
assert.equal(contextLimitForModel("claude-code:opus", catalog, null), null, "no placeholder fallback");
assert.equal(contextLimitForModel("anthropic:fixture", catalog, window), 100000);
assert.equal(contextLimitForModel(undefined, catalog, window), null);
assert.equal(formatContextMeasurement(42000, 200000), "42k / 200k");
assert.equal(formatContextMeasurement(42000, null), "42k");
const unreported = assistant("opus"); delete unreported.cliContextWindow;
assert.equal(cliContextWindowOf([first, unreported]), null);
const other = assistant("api"); other.provider = "anthropic";
assert.equal(cliContextWindowOf([first, other]), null);
const compacted: LhMessage = { id: "compact", role: "custom", customType: "compaction", content: "summary", display: false, details: {}, timestamp: 1 };
assert.equal(cliContextWindowOf([first, compacted]), null);
assert.equal(cliContextWindowOf([]), null);
});
test("CLI catalog limits remain unknown and cannot create a global denominator", () => {
const models = CLAUDE_CODE_MODELS.map(toModelDto);
assert(models.every((model) => model.contextWindow === null && model.maxTokens === null));
assert.equal(catalogLimits({ models }).size, 0);
});
+319
View File
@@ -0,0 +1,319 @@
/**
* Claude Code provider driven through the real chat handler: a learner-facing
* question ends the turn while the CLI process waits on the held MCP call, and
* the learner's answer arrives later as that call's tool result.
*
* The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, one
* scenario per spawn, so the spawn count shows whether the answer continued
* the waiting process or started a fresh one with a replay.
*/
import assert from "node:assert/strict";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import test, { after, type TestContext } from "node:test";
import type { RunClaude } from "../src/agent/claude-code/stream.ts";
import type { PiEngine } from "../src/agent/models.ts";
// Opt-in writes must not race another test process's settings record.
const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-"));
process.env.LH_DATA_DIR = testData;
after(() => rmSync(testData, { recursive: true, force: true }));
const { PATHS, SETTINGS_FILE } = await import("../config.ts");
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
const { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } = await import("../src/agent/claude-code/live-processes.ts");
const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts");
const { createClaudeCodeStreamFunction } = await import("../src/agent/claude-code/stream.ts");
const { createTeacherAgent } = await import("../src/agent/teacher-agent.ts");
const { cliContextWindowOf, danglingQuestionOf } = await import("../src/core/native-messages.ts");
const { handleChatRequest } = await import("../src/server/chat-handler.ts");
const { isTurnRunning } = await import("../src/server/turn-liveness.ts");
const { runningTurn } = await import("../src/server/turn-runs.ts");
const { chatStore } = await import("../src/store/chats.ts");
const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url));
const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url));
const TURN_TIMEOUT_MS = 8000;
const ASK_ID = "toolu_harness_ask";
const ASK_INPUT = { question: "What do you already know about sockets?" };
const ANSWER_TEXT = "Only that they carry bytes between programs";
type FakeRun = { runClaude: RunClaude; children: ChildProcessWithoutNullStreams[]; stdinFiles: string[] };
function streamEvent(event: object): string {
return JSON.stringify({ type: "stream_event", event });
}
function toAskScenario(): string[] {
return [
streamEvent({ type: "message_start", message: { usage: {} } }),
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: ASK_ID, name: "mcp__learnharness__ask_user_question", input: {} },
}),
streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(ASK_INPUT) } }),
streamEvent({ type: "content_block_stop", index: 0 }),
streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
streamEvent({ type: "message_stop" }),
JSON.stringify({ fake: "call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
JSON.stringify({ fake: "reply" }),
];
}
function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun {
const settings = readSettings();
writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled: true } });
t.after(() => { if (settings) writeSettings(settings); else rmSync(SETTINGS_FILE, { force: true }); });
const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
// Satisfy the real presence gate; this marker is NEVER executed. All work
// below goes through the injected Node fake-CLI seam, not an installed CLI.
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`;
t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; });
const children: ChildProcessWithoutNullStreams[] = [];
const stdinFiles: string[] = [];
const runClaude: RunClaude = (args, options) => {
const scenario = scenarios[children.length];
assert.ok(scenario, `unexpected spawn #${children.length + 1}`);
const scenarioFile = join(dir, `scenario-${children.length}.jsonl`);
const stdinFile = join(dir, `stdin-${children.length}.jsonl`);
writeFileSync(scenarioFile, `${scenario.join("\n")}\n`);
const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], {
cwd: options.cwd,
env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile },
stdio: ["pipe", "pipe", "pipe", "ipc"],
}) as ChildProcessWithoutNullStreams;
t.after(() => child.kill("SIGKILL"));
children.push(child);
stdinFiles.push(stdinFile);
return child;
};
return { runClaude, children, stdinFiles };
}
function createClaudeCodeChat(t: TestContext): string {
const chat = chatStore.createChat("Claude Code: question answered later");
chatStore.saveSettings(chat.id, { model: { provider: "claude-code", id: "sonnet" } });
t.after(() => unlinkSync(join(PATHS.chats, `${chat.id}.json`)));
return chat.id;
}
function createAgentFactory(fakeRun: FakeRun): Parameters<typeof handleChatRequest>[1] {
const model = CLAUDE_CODE_MODELS.find((candidate) => candidate.id === "sonnet");
assert.ok(model);
const engine: PiEngine = { model, streamFn: createClaudeCodeStreamFunction(fakeRun.runClaude) };
return (options) => createTeacherAgent(undefined, { engine, chatId: options.chatId, messages: options.messages });
}
async function runChatTurn(chatId: string, input: object, agentFactory: Parameters<typeof handleChatRequest>[1]): Promise<void> {
const request = new Request("http://test/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input }),
});
const ack = await handleChatRequest(request, agentFactory);
assert.equal(ack.status, 200, await ack.clone().text());
const handle = runningTurn(chatId);
if (handle) await new Response(handle.subscribe()).text();
const deadline = Date.now() + TURN_TIMEOUT_MS;
while (isTurnRunning(chatId)) {
assert.ok(Date.now() < deadline, "turn did not settle");
await sleep(10);
}
}
function toAnswerInput(): object {
return {
type: "answer",
toolCallId: ASK_ID,
answer: { kind: "ask", questionText: ASK_INPUT.question, dontKnow: false, answers: [], text: ANSWER_TEXT, answeredAt: new Date().toISOString() },
};
}
function getLastAssistantText(chatId: string): string {
const messages = chatStore.getChat(chatId)?.messages ?? [];
const assistant = messages.findLast((message) => message.role === "assistant");
assert.ok(assistant?.role === "assistant");
return assistant.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
}
async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) });
}
test("disabled opt-in refuses a saved CLI chat before write-ahead, liveness, or agent creation", async (t) => {
const fakeRun = createFakeRun(t, [toAskScenario()]);
const chatId = createClaudeCodeChat(t);
writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } });
const before = chatStore.getChat(chatId)!.messages;
const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input: { type: "text", text: "must not start" } }) }), () => assert.fail("disabled work must not build an agent"));
assert.equal(response.status, 503);
assert.match((await response.json()).error, /Enable and set it up in Settings/);
assert.equal(isTurnRunning(chatId), false); assert.equal(fakeRun.children.length, 0);
assert.deepEqual(chatStore.getChat(chatId)!.messages, before);
assert.deepEqual(chatStore.getChat(chatId)!.settings?.model, { provider: "claude-code", id: "sonnet" });
});
test("switching off blocks future requests but does not interrupt an already admitted CLI turn", async (t) => {
const scenario = [JSON.stringify({ fake: "checkpoint" }), JSON.stringify({ fake: "wait-for-message" }), ...readFileSync(TEXT_FIXTURE, "utf8").trim().split("\n")];
const fakeRun = createFakeRun(t, [scenario]);
const run = fakeRun.runClaude;
let ready!: (child: ChildProcessWithoutNullStreams) => void;
let failed!: (error: unknown) => void;
const checkpoint = new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => { ready = resolve; failed = reject; });
fakeRun.runClaude = async (args, options) => {
const child = await run(args, options); assert(child);
void once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }).then(() => ready(child), failed);
return child;
};
const chatId = createClaudeCodeChat(t);
const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input: { type: "text", text: "complete this admitted fixture" } }) }), createAgentFactory(fakeRun));
assert.equal(response.status, 200);
const child = await checkpoint;
const handle = runningTurn(chatId); assert(handle);
const finished = new Response(handle.subscribe()).text();
writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } });
assert.equal(child.signalCode, null, "opt-out must not kill an already running process");
child.send("continue"); await finished;
assert(getLastAssistantText(chatId).length > 0, "the admitted turn completes normally");
assert.equal(readSettings()?.claudeCode?.enabled, false);
await waitForExit(child);
});
test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => {
const fakeRun = createFakeRun(t, [toAskScenario()]);
const run = fakeRun.runClaude;
fakeRun.runClaude = (args, options) => run(args, { ...options, env: { ...options.env, FAKE_CLAUDE_WINDOW: "200000" } });
const agentFactory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
const [waiting] = fakeRun.children;
assert.ok(waiting);
assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call");
await runChatTurn(chatId, toAnswerInput(), agentFactory);
assert.equal(fakeRun.children.length, 1);
assert.deepEqual(cliContextWindowOf(chatStore.getChat(chatId)!.messages), { model: "claude-code:sonnet", tokens: 200000 });
assert.deepEqual(cliContextWindowOf(JSON.parse(readFileSync(join(PATHS.chats, `${chatId}.json`), "utf8")).messages), { model: "claude-code:sonnet", tokens: 200000 });
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId));
await waitForExit(waiting);
});
test("a question pauses later calls from the same CLI response until the learner answers", async (t) => {
const secondId = "toolu_second_question";
const secondQuestion = "What do you want to learn next?";
const scenario = toAskScenario();
scenario.splice(4, 0,
streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: secondId, name: "mcp__learnharness__ask_user_question", input: {} } }),
streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question: secondQuestion }) } }),
streamEvent({ type: "content_block_stop", index: 1 }),
);
scenario.splice(scenario.length - 1, 0, JSON.stringify({ fake: "call", id: secondId, name: "ask_user_question", arguments: { question: secondQuestion } }));
const fakeRun = createFakeRun(t, [scenario]);
const factory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(secondId), false, "unadmitted second question must not be posed or persisted");
await runChatTurn(chatId, toAnswerInput(), factory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, secondId);
await runChatTurn(chatId, {
type: "answer", toolCallId: secondId,
answer: { kind: "ask", questionText: secondQuestion, dontKnow: false, answers: [], text: "HTTP next", answeredAt: new Date().toISOString() },
}, factory);
assert.equal(fakeRun.children.length, 1);
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT));
assert.ok(getLastAssistantText(chatId).includes("HTTP next"));
await waitForExit(fakeRun.children[0]!);
});
test("an individually cancelled MCP question queued behind a learner wait never executes", async (t) => {
const cancelledId = "toolu_cancelled_question";
const question = "This cancelled question must never be posed";
const scenario = toAskScenario();
scenario.splice(4, 0,
streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: cancelledId, name: "mcp__learnharness__ask_user_question", input: {} } }),
streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question }) } }),
streamEvent({ type: "content_block_stop", index: 1 }),
);
scenario.splice(scenario.length - 2, 2,
JSON.stringify({ fake: "start-call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
JSON.stringify({ fake: "wait-for-message" }),
JSON.stringify({ fake: "start-call", id: cancelledId, name: "ask_user_question", arguments: { question } }),
JSON.stringify({ fake: "cancel-call", id: cancelledId }),
JSON.stringify({ fake: "checkpoint" }),
JSON.stringify({ fake: "reply" }),
);
const fakeRun = createFakeRun(t, [scenario]);
const factory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
const child = fakeRun.children[0]!;
const cancelledOnServer = once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) });
child.send("queue and cancel B");
assert.deepEqual((await cancelledOnServer)[0], { fake: "checkpoint" });
await runChatTurn(chatId, toAnswerInput(), factory);
assert.equal(fakeRun.children.length, 1);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? []), null);
assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(cancelledId), false, "cancelled B must never reach native tool execution or persistence");
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT));
await waitForExit(child);
});
test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => {
liveProcessIdleTimeoutMs.current = 300;
t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS));
const fakeRun = createFakeRun(t, [toAskScenario(), readFileSync(TEXT_FIXTURE, "utf8").split("\n").filter(Boolean)]);
const agentFactory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory);
const [waiting] = fakeRun.children;
assert.ok(waiting);
assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call");
await waitForExit(waiting);
await runChatTurn(chatId, toAnswerInput(), agentFactory);
assert.equal(fakeRun.children.length, 2);
assert.equal(getLastAssistantText(chatId), "hello there");
const replay = readFileSync(fakeRun.stdinFiles[1] ?? "", "utf8");
assert.ok(replay.includes("[tool call ask_user_question]"), replay);
assert.ok(replay.includes(ASK_INPUT.question), replay);
assert.ok(replay.includes("[tool result ask_user_question]"), replay);
assert.ok(replay.includes(ANSWER_TEXT), replay);
});
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process";
import { test } from "node:test";
import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const binary = "/fake/claude";
const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } };
test("missing installation does not execute a command", async () => {
assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), {
state: "not-found", version: null, minimumVersion: "2.1.259",
});
});
for (const [output, state, version] of [
["2.1.258 (Claude Code)\n", "update-required", "2.1.258"],
["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"],
["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"],
["2.2.0", "meets-minimum", "2.2.0"],
["2.0.999", "update-required", "2.0.999"],
["3.0.0", "meets-minimum", "3.0.0"],
["unexpected output containing private details", "version-unchecked", null],
["2.1.310-preview", "version-unchecked", null],
["99999999999999999999999999.1.1", "version-unchecked", null],
] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => {
const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; });
assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" });
});
test("probe failures expose neither raw errors nor binary paths", async () => {
const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); });
assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" });
});
for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => {
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; },
spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn,
});
await assert.rejects(launch(["-p"], options) as Promise<unknown>, ClaudeLaunchError);
});
test("launcher uses the verified executable directly and preserves model args and native env", async () => {
const child = {} as ChildProcessWithoutNullStreams;
let calls = 0;
const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)",
spawnProcess: ((path: string, args: string[], opts: unknown) => {
calls++;
assert.equal(path, binary);
assert.deepEqual(args, ["-p", "--tools", ""]);
assert.deepEqual(opts, { ...options, stdio: "pipe" });
return child;
}) as typeof spawn,
});
assert.equal(await launch(["-p", "--tools", ""], options), child);
assert.equal(calls, 1);
});
test("aborting during a version check prevents model process creation", async () => {
const controller = new AbortController();
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { controller.abort(); return "2.1.310"; },
spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn,
});
await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise<unknown>, /aborted/);
});
test("missing executable is not mistaken for an unchecked version", async () => {
const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") });
assert.equal(await launch([], options), null);
});
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import test, { after, type TestContext } from "node:test";
const testData = mkdtempSync(join(tmpdir(), "lh-cli-opt-in-data-"));
process.env.LH_DATA_DIR = testData;
after(() => rmSync(testData, { recursive: true, force: true }));
const { SETTINGS_FILE } = await import("../config.ts");
const { PUT } = await import("../app/api/settings/[section]/route.ts");
const { sanitizeUserSettings, providerKeyMissing } = await import("../src/core/settings.ts");
const { parseClaudeCodeSettingsBody } = await import("../src/server/settings-body.ts");
const { claudeCodeAvailable, claudeCodeEnabled } = await import("../src/server/claude-code-config.ts");
const { claudeCodeInstallationResponse } = await import("../src/server/claude-code.ts");
const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts");
const { providerKeyPresent } = await import("../src/server/keys.ts");
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
const { chatStore } = await import("../src/store/chats.ts");
const { jobStore } = await import("../src/store/jobs.ts");
const { createJobRunner } = await import("../src/agent/subagents/runner.ts");
const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts");
function setup(t: TestContext) {
const previous = readSettings();
const path = process.env.PATH;
const dir = mkdtempSync(join(tmpdir(), "claude-opt-in-"));
// Never executed: only installation discovery observes this marker.
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
process.env.PATH = `${dir}${delimiter}${path ?? ""}`;
t.after(() => {
if (previous) writeSettings(previous); else rmSync(SETTINGS_FILE, { force: true });
if (path === undefined) delete process.env.PATH; else process.env.PATH = path;
rmSync(dir, { recursive: true, force: true });
});
writeSettings({ version: 1 });
}
async function save(body: unknown) {
return PUT(new Request("http://test/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
{ params: Promise.resolve({ section: "claudeCode" }) });
}
test("opt-in parses strictly and malformed stored values never enable the CLI", () => {
assert.deepEqual(parseClaudeCodeSettingsBody({}), { ok: true, settings: {} });
assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: true, ignored: true }), { ok: true, settings: { enabled: true } });
assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: false }), { ok: true, settings: { enabled: false } });
for (const raw of [true, null, [], "on", { enabled: "true" }, { enabled: 1 }]) assert.equal(parseClaudeCodeSettingsBody(raw).ok, false);
for (const raw of [null, [], true, "on", { enabled: "true" }, { enabled: 1 }]) {
assert.notEqual(sanitizeUserSettings({ version: 1, claudeCode: raw })?.claudeCode?.enabled, true);
}
assert.equal(sanitizeUserSettings({ version: 1 })?.claudeCode, undefined);
});
test("default off even with an installed binary; section writes toggle availability but preserve saved model pins and unrelated settings", async (t) => {
setup(t);
const models = { strong: { model: { provider: "claude-code", id: "sonnet" } }, cheap: { model: { provider: "claude-code", id: "haiku" } } };
writeSettings({ version: 1, models, graph: { enabled: false }, futureSection: { keep: 1 } });
assert.equal(claudeCodeEnabled(), false); assert.equal(claudeCodeAvailable(), false);
assert.equal(providerKeyPresent("claude-code"), false);
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
assert.equal((await save({ enabled: true })).status, 200);
assert.equal(claudeCodeEnabled(), true); assert.equal(claudeCodeAvailable(), true);
assert.equal(modelCatalog().models.filter((m) => m.provider === "claude-code").length, 3);
assert.equal((await save({ enabled: false })).status, 200);
assert.equal(claudeCodeAvailable(), false);
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
assert.deepEqual(readSettings()?.models, models);
assert.deepEqual(readSettings()?.graph, { enabled: false });
assert.deepEqual(readSettings()?.futureSection, { keep: 1 });
assert.equal(validateModelsSettings(models, models).ok, true, "saved unavailable pins remain repairable, not silently removed");
assert.equal((await save({ enabled: "true" })).status, 400); assert.equal(claudeCodeEnabled(), false);
assert.equal((await save({})).status, 200); assert.equal(claudeCodeEnabled(), false);
});
test("off cannot launch a version probe even through an otherwise authorized HTTP request", async (t) => {
setup(t);
const request = new Request("http://test/api/claude-code", { headers: { "x-learnharness-claude-code": "1" } });
const response = await claudeCodeInstallationResponse(request, async () => assert.fail("disabled CLI must not execute"));
assert.equal(response.status, 403);
});
test("a saved CLI background job is refused before model execution while opt-in is off", async (t) => {
setup(t);
const chat = chatStore.createChat("CLI opt-in job fixture");
t.after(() => { jobStore.deleteJobsForChat(chat.id); chatStore.deleteChat(chat.id); });
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "cli-off-job" });
let calls = 0;
const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {},
engineFor: () => ({ model: CLAUDE_CODE_MODELS[0]!, streamFn: () => { calls++; assert.fail("must not run a disabled CLI model"); } }),
providerKeyMissing: (provider, tier) => providerKeyPresent(provider) ? null : providerKeyMissing(provider, [tier]),
});
await runner.runJob(job.id, { question: "Synthetic fixture only" });
assert.equal(calls, 0);
const refused = jobStore.getJob(job.id)!;
assert.equal(refused.status, "error");
assert.match(JSON.stringify(refused.result), /Enable and set it up in Settings/);
assert(!JSON.stringify(refused.result).includes("add one in Settings → Keys"));
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Claude Code provider catalog (src/agent/claude-code/provider.ts).
*
* Registered on a fresh pi model collection with a stub stream function, so
* only the catalog and auth surface are under test here.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { clampThinkingLevel, createAssistantMessageEventStream, createModels, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { CLAUDE_CODE_PROVIDER } from "../src/agent/claude-code/constants.ts";
import { createClaudeCodeProvider } from "../src/agent/claude-code/provider.ts";
function createCollection() {
const collection = createModels();
collection.setProvider(createClaudeCodeProvider(() => createAssistantMessageEventStream()));
return collection;
}
test("lists exactly the opus, sonnet and haiku aliases", () => {
const models = createCollection().getModels(CLAUDE_CODE_PROVIDER);
assert.deepEqual(
models.map((m) => m.id),
["opus", "sonnet", "haiku"],
);
assert.deepEqual(
models.map((m) => m.name),
["Claude Opus", "Claude Sonnet", "Claude Haiku"],
);
});
test("supports low through max thinking and clamps off to low", () => {
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
assert.deepEqual(getSupportedThinkingLevels(model), ["low", "medium", "high", "xhigh", "max"]);
assert.equal(clampThinkingLevel(model, "off"), "low");
assert.equal(clampThinkingLevel(model, "minimal"), "low");
}
});
test("accepts text and image input", () => {
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
assert.deepEqual(model.input, ["text", "image"]);
}
});
test("every cost field is zero", () => {
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
assert.deepEqual(model.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
}
});
test("auth resolves as configured without any key", async () => {
const collection = createCollection();
assert.ok(await collection.checkAuth(CLAUDE_CODE_PROVIDER));
assert.equal((await collection.getAvailable(CLAUDE_CODE_PROVIDER)).length, 3);
});
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { claudeCodeInstallationResponse as respond } from "../src/server/claude-code.ts";
const claudeCodeInstallationResponse: typeof respond = (request, inspect) => respond(request, inspect, () => true);
function request(headers: Record<string, string> = {}) {
return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } });
}
const status = { state: "not-found" as const, version: null, minimumVersion: "2.1.259" };
for (const headers of [
{ origin: "https://attacker.invalid" },
{ "sec-fetch-site": "cross-site" },
{ origin: "http://localhost:3000", "sec-fetch-site": "cross-site" },
{ origin: "null" },
{ "x-learnharness-claude-code": "" },
] as Record<string, string>[]) test(`unsafe status request ${JSON.stringify(headers)} never invokes the probe`, async () => {
let probes = 0;
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
assert.equal(response.status, 403); assert.equal(probes, 0);
assert.equal(response.headers.get("access-control-allow-origin"), null);
});
test("disabled opt-in never starts even a version probe", async () => {
let probes = 0;
const response = await respond(request(), async () => { probes++; return status; }, () => false);
assert.equal(response.status, 403); assert.equal(probes, 0);
});
test("ordinary image/subresource GET without intent header cannot start a probe", async () => {
const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe"));
assert.equal(response.status, 403);
});
for (const headers of [{}, { origin: "http://localhost:3000", "sec-fetch-site": "same-origin" }] as Record<string, string>[]) test(`legitimate Providers GET ${JSON.stringify(headers)} is supported`, async () => {
let probes = 0;
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
assert.equal(response.status, 200); assert.equal(probes, 1);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), status);
});
test("a forged forwarded origin cannot authorize a foreign Origin header", async () => {
const response = await claudeCodeInstallationResponse(request({ origin: "https://attacker.invalid", "x-forwarded-host": "attacker.invalid", "x-forwarded-proto": "https" }), async () => assert.fail("must not probe"));
assert.equal(response.status, 403);
});
+141
View File
@@ -0,0 +1,141 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
import { ConnectionRow } from "../components/settings/connection-controls.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined;
const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" });
const button = () => document.querySelector<HTMLButtonElement>('[data-testid="claude-code-check-again"]')!;
const toggle = () => document.querySelector<HTMLButtonElement>('[role="switch"]')!;
const label = () => document.querySelector('[role="status"]')?.textContent;
function fixture(probe: (init?: RequestInit) => Promise<Response>, enabled = true, failSave = false) {
const calls: string[] = [];
let on = enabled;
globalThis.fetch = (async (url, init) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url === "/api/settings") return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } });
if (url === "/api/settings/claudeCode") {
assert.equal(init?.method, "PUT"); assert.equal(new Headers(init.headers).get("Content-Type"), "application/json");
if (failSave) return new Response(null, { status: 500 });
on = JSON.parse(String(init.body)).enabled;
return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } });
}
if (url === "/api/models") return Response.json({ models: on ? [{ provider: "claude-code", id: "sonnet" }] : [] });
assert.equal(url, "/api/claude-code");
assert.equal(init?.cache, "no-store");
assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1");
return probe(init);
}) as typeof fetch;
return calls;
}
async function mount(extra?: React.ReactNode) {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render(<><ClaudeCodeInstallationStatus />{extra}</>));
}
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("initial render is collapsed and off, with no setup prose or version probe action", () => {
const html = renderToStaticMarkup(<ClaudeCodeInstallationStatus />);
assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/);
assert.equal((html.match(/<button/g) ?? []).length, 1);
assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗"));
});
test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => {
const calls = fixture(async () => Response.json(status("not-found")), false);
await mount();
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
assert.deepEqual(calls, ["GET /api/settings"]);
await act(async () => toggle().click());
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found");
assert(calls.indexOf("PUT /api/settings/claudeCode") < calls.indexOf("GET /api/claude-code"));
assert.notEqual(toggle().parentElement, button().parentElement);
assert.equal(document.querySelector('[role="status"]')?.parentElement, button().parentElement);
assert.match(document.body.textContent ?? "", /no background process required/);
assert.match(document.body.textContent ?? "", /not authentication, model access, or quota/);
assert.match(document.body.textContent ?? "", /Claude Code controls billing/);
assert.match(document.body.textContent ?? "", /Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated\./);
assert(document.querySelector("strong")?.textContent?.includes("Install and authenticate"));
assert.equal(document.querySelector("a")?.getAttribute("href"), "https://code.claude.com/docs/en/setup");
await act(async () => toggle().click());
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); assert.equal(label(), undefined);
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 1);
});
test("recheck matches Connect styling; Manage remains ghost", async () => {
fixture(async () => Response.json(status("not-found"))); await mount();
const row = (action: string) => parseHTML(renderToStaticMarkup(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />)).document.querySelector("button")!;
assert.equal(button().getAttribute("class"), row("Connect").getAttribute("class"));
assert.equal(button().getAttribute("data-variant"), "outline");
assert.equal(row("Manage").getAttribute("data-variant"), "ghost");
});
for (const [value, text] of [
[status("not-found"), "Not found"],
[status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"],
[status("update-required", "2.1.258"), "Version 2.1.258 · update required"],
[status("version-unchecked"), "Version could not be checked"],
] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text));
test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => {
let probes = 0;
const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310")));
await mount(); assert.equal(label(), "Not found");
await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum");
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2);
});
test("failed opt-in save stays collapsed/off and never probes", async () => {
const calls = fixture(async () => assert.fail("must not probe"), false, true);
await mount(); await act(async () => toggle().click());
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
assert.match(document.querySelector('[role="alert"]')?.textContent ?? "", /Could not save/);
assert(!calls.includes("GET /api/claude-code"));
});
test("slow checks disable recheck; switching off aborts pending version work without starting another probe", async () => {
let signal: AbortSignal | null | undefined;
let resolve!: (response: Response) => void;
fixture((init) => { signal = init?.signal; return new Promise<Response>((done) => { resolve = done; }); });
await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled);
await act(async () => toggle().click()); assert(signal?.aborted); assert(!button());
await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined);
});
for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => {
let calls = 0;
fixture(async () => {
if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310"));
if (failure === "network") throw new Error("offline");
return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" });
});
await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum");
await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled);
});
function CatalogReader() {
const { catalog } = useModelCatalog();
return <span data-testid="cli-catalog-models">{catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}</span>;
}
test("persisted toggle updates invalidate shared model availability in both directions", async () => {
fixture(async () => Response.json(status("not-found")), false);
await mount(<CatalogReader />);
const models = () => document.querySelector('[data-testid="cli-catalog-models"]')?.textContent;
assert.equal(models(), "");
await act(async () => toggle().click()); assert.equal(models(), "claude-code:sonnet");
await act(async () => toggle().click()); assert.equal(models(), "");
});
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
/**
* Claude Code MCP tool bridge (src/agent/claude-code/tool-bridge.ts).
*
* A real MCP SDK client talks to the bridge over loopback HTTP, the same way
* the claude CLI does, so listing, auth, pending calls and their results
* are all exercised end to end.
*/
import assert from "node:assert/strict";
import * as net from "node:net";
import * as os from "node:os";
import test from "node:test";
import type { Tool, ToolResultMessage } from "@earendil-works/pi-ai";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Type } from "typebox";
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type ToolBridge } from "../src/agent/claude-code/tool-bridge.ts";
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: Type.Object({ path: Type.String() }) };
const SCREENSHOT_TOOL: Tool = { name: "screenshot", description: "Capture the screen", parameters: Type.Object({}) };
type BridgeServerConfig = { type: string; url: string; headers: { Authorization: string } };
function getServerConfig(bridge: ToolBridge): BridgeServerConfig {
const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record<string, BridgeServerConfig> };
const server = config.mcpServers[BRIDGE_SERVER_NAME];
assert.ok(server);
return server;
}
async function connectClient(bridge: ToolBridge, authorization = getServerConfig(bridge).headers.Authorization): Promise<Client> {
const transport = new StreamableHTTPClientTransport(new URL(getServerConfig(bridge).url), {
requestInit: { headers: { Authorization: authorization } },
});
const client = new Client({ name: "test", version: "0" });
await client.connect(transport);
return client;
}
function toolResult(content: ToolResultMessage["content"], isError = false): ToolResultMessage {
return { role: "toolResult", toolCallId: "call-1", toolName: "read", content, isError, timestamp: 0 };
}
test("lists exactly the given tools with their JSON schemas", async (t) => {
const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
const { tools } = await client.listTools();
assert.deepEqual(
tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
[
{ name: "read", description: "Read a file", inputSchema: JSON.parse(JSON.stringify(READ_TOOL.parameters)) },
{ name: "screenshot", description: "Capture the screen", inputSchema: JSON.parse(JSON.stringify(SCREENSHOT_TOOL.parameters)) },
],
);
assert.deepEqual(bridge.allowedTools, ["mcp__learnharness__read", "mcp__learnharness__screenshot"]);
});
test("rejects requests without the bearer token", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
await assert.rejects(connectClient(bridge, "Bearer wrong"), (error: { code?: number }) => error.code === 401);
const response = await fetch(getServerConfig(bridge).url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
assert.equal(response.status, 401);
});
test("binds 127.0.0.1", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const url = new URL(getServerConfig(bridge).url);
assert.equal(url.hostname, "127.0.0.1");
const externalAddress = Object.values(os.networkInterfaces())
.flat()
.find((address) => address && !address.internal && address.family === "IPv4");
if (!externalAddress) {
t.diagnostic("no non-loopback IPv4 interface; skipped external connect check");
return;
}
await assert.rejects(
new Promise((resolve, reject) => {
const socket = net.connect(Number(url.port), externalAddress.address, () => resolve(socket.end()));
socket.once("error", reject);
}),
{ code: "ECONNREFUSED" },
);
});
test("tools/call stays pending until resolved and returns its text and image content", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
let settled = false;
const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).finally(() => {
settled = true;
});
const [call] = await bridge.waitForToolCalls(1);
assert.ok(call);
assert.deepEqual(call, { id: "call-1", name: "read", arguments: { path: "notes.md" } });
assert.deepEqual(bridge.getPendingToolCalls(), [call]);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(settled, false);
bridge.resolveToolCall(
call,
toolResult([
{ type: "text", text: "# Notes" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
]),
);
const result = await pending;
assert.deepEqual(result.content, [
{ type: "text", text: "# Notes" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
]);
assert.notEqual(result.isError, true);
assert.deepEqual(bridge.getPendingToolCalls(), []);
});
test("individual MCP cancellation removes only that request and leaves the other request resolvable", async (t) => {
let acknowledgeCancel!: () => void;
const cancelled = new Promise<void>((resolve) => { acknowledgeCancel = resolve; });
const cancellations: string[] = [];
const bridge = await createToolBridge([READ_TOOL], {
onCall() {}, onError(error) { throw error; },
onCancel(call) { cancellations.push(call.id); acknowledgeCancel(); },
});
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
const first = client.callTool({ name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "call-a" } });
const controller = new AbortController();
const second = client.callTool({ name: "read", arguments: { path: "b" }, _meta: { "claudecode/toolUseId": "call-b" } }, undefined, { signal: controller.signal });
const rejected = assert.rejects(second, /cancelled B/);
await bridge.waitForToolCalls(2);
controller.abort("cancelled B");
await cancelled;
await rejected;
assert.deepEqual(cancellations, ["call-b"]);
const pending = bridge.getPendingToolCalls();
assert.deepEqual(pending.map((call) => call.id), ["call-a"]);
bridge.resolveToolCall(pending[0]!, toolResult([{ type: "text", text: "A" }]));
assert.deepEqual((await first).content, [{ type: "text", text: "A" }]);
assert.deepEqual(bridge.getPendingToolCalls(), []);
});
test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => {
const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
let waited = false;
const waiting = bridge.waitForToolCalls(2).finally(() => {
waited = true;
});
void client.callTool({ name: "read", arguments: { path: "a.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(waited, false);
void client.callTool({ name: "screenshot", arguments: {}, _meta: { "claudecode/toolUseId": "call-2" } }).catch(() => {});
assert.deepEqual(await waiting, [
{ id: "call-1", name: "read", arguments: { path: "a.md" } },
{ id: "call-2", name: "screenshot", arguments: {} },
]);
});
test("isError result becomes MCP isError true", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
const pending = client.callTool({ name: "read", arguments: { path: "missing.md" }, _meta: { "claudecode/toolUseId": "call-1" } });
const [call] = await bridge.waitForToolCalls(1);
assert.ok(call);
bridge.resolveToolCall(call, toolResult([{ type: "text", text: "ENOENT" }], true));
const result = await pending;
assert.equal(result.isError, true);
assert.deepEqual(result.content, [{ type: "text", text: "ENOENT" }]);
});
test("close fails pending calls and frees the port", async () => {
const bridge = await createToolBridge([READ_TOOL]);
const port = Number(new URL(getServerConfig(bridge).url).port);
const client = await connectClient(bridge);
const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } });
await bridge.waitForToolCalls(1);
await bridge.close();
await assert.rejects(pending);
assert.deepEqual(bridge.getPendingToolCalls(), []);
await client.close();
const probe = net.createServer();
await new Promise<void>((resolve, reject) => {
probe.once("error", reject);
probe.listen(port, "127.0.0.1", resolve);
});
await new Promise((resolve) => probe.close(resolve));
});
test("close rejects consumers still waiting for tool calls", { timeout: 1000 }, async () => {
const bridge = await createToolBridge([READ_TOOL]);
const waiting = bridge.waitForToolCalls(1);
await bridge.close();
await assert.rejects(waiting, /tool bridge closed/);
});
test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"headers":{"Authorization":"Bearer ..."}}}}', async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record<string, unknown> };
assert.deepEqual(Object.keys(config.mcpServers), ["learnharness"]);
const server = getServerConfig(bridge);
assert.deepEqual(Object.keys(server).sort(), ["headers", "type", "url"]);
assert.equal(server.type, "http");
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/);
});
test("missing and reused Claude tool IDs are rejected rather than matched by arguments", async (t) => {
const failures: string[] = [];
const bridge = await createToolBridge([READ_TOOL], { onCall: () => {}, onError: (error) => failures.push(error.message) });
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
await assert.rejects(client.callTool({ name: "read", arguments: { path: "a" } }), /claudecode\/toolUseId/);
assert.deepEqual(bridge.getPendingToolCalls(), []);
const params = { name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "same-id" } };
const first = client.callTool(params);
const [call] = await bridge.waitForToolCalls(1);
assert.ok(call);
bridge.resolveToolCall(call, toolResult([{ type: "text", text: "first" }]));
await first;
await assert.rejects(client.callTool(params), /claudecode\/toolUseId/);
assert.deepEqual(bridge.getPendingToolCalls(), []);
assert.equal(failures.length, 2);
});
test("toolNameFromMcp strips the bridge prefix", () => {
assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read");
assert.equal(toolNameFromMcp("read"), "read");
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Claude Code transcript replay (src/agent/claude-code/transcript.ts).
*
* The CLI makes one model call per input user message, so earlier turns are
* rendered as labelled text inside the single user message it receives.
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { AssistantMessage, Message, SystemMessage, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import { toClaudeInput, toStreamJsonLine } from "../src/agent/claude-code/transcript.ts";
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } };
const WRITE_TOOL: Tool = { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } };
function system(fields: Partial<SystemMessage>): SystemMessage {
return { role: "system", content: "", timestamp: 0, ...fields };
}
function user(content: UserMessage["content"]): UserMessage {
return { role: "user", content, timestamp: 0 };
}
function assistant(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "claude-code-cli",
provider: "claude-code",
model: "opus",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 0,
};
}
function toolResult(toolName: string, content: ToolResultMessage["content"], isError = false): ToolResultMessage {
return { role: "toolResult", toolCallId: `${toolName}-1`, toolName, content, isError, timestamp: 0 };
}
test("system prompt joins all system content, then the live sections", () => {
const messages: Message[] = [
system({ content: "You are a tutor.", sections: { goals: "Teach TCP.", style: "Be brief." } }),
user("hi"),
system({ content: "The learner switched topics.", sections: { goals: "Teach TLS.", style: null } }),
user("next"),
];
assert.equal(toClaudeInput(messages).systemPrompt, "You are a tutor.\n\nThe learner switched topics.\n\nTeach TLS.");
});
test("tools reflect toolsAdded minus toolsRemoved", () => {
const messages: Message[] = [
system({ content: "p", toolsAdded: [READ_TOOL, WRITE_TOOL] }),
user("hi"),
system({ toolsRemoved: [{ name: "read" }] }),
user("next"),
];
assert.deepEqual(
toClaudeInput(messages).tools.map((t) => t.name),
["write"],
);
});
test("single user message passes through as text", () => {
const input = toClaudeInput([system({ content: "p" }), user("what is a socket?")]);
assert.deepEqual(input.userContent, [{ type: "text", text: "what is a socket?" }]);
});
test("history renders user, assistant text, tool call name+JSON args and tool result text in order before the latest user message", () => {
const messages: Message[] = [
user([{ type: "text", text: "open notes.md" }]),
assistant([
{ type: "text", text: "Reading it." },
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } },
]),
toolResult("read", [{ type: "text", text: "# Notes" }]),
assistant([{ type: "text", text: "It has a heading." }]),
user("summarise it"),
];
const expected = [
"[user]\nopen notes.md",
"[assistant]\nReading it.",
'[tool call read] {"path":"notes.md"}',
"[tool result read]\n# Notes",
"[assistant]\nIt has a heading.",
"[user]\nsummarise it",
].join("\n\n");
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
});
test("error tool results are marked as errors in the replay", () => {
const messages: Message[] = [
user("open missing.md"),
assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "missing.md" } }]),
toolResult("read", [{ type: "text", text: "ENOENT" }], true),
user("why?"),
];
const expected = [
"[user]\nopen missing.md",
'[tool call read] {"path":"missing.md"}',
"[tool result read error]\nENOENT",
"[user]\nwhy?",
].join("\n\n");
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
});
test("images in user and tool-result content become base64 image blocks", () => {
const messages: Message[] = [
user([
{ type: "text", text: "look" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
]),
assistant([{ type: "toolCall", id: "shot-1", name: "screenshot", arguments: {} }]),
toolResult("screenshot", [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]),
user("compare"),
];
assert.deepEqual(toClaudeInput(messages).userContent, [
{ type: "text", text: "[user]\nlook" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } },
{ type: "text", text: "[tool call screenshot] {}\n\n[tool result screenshot]" },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "BBBB" } },
{ type: "text", text: "[user]\ncompare" },
]);
});
test("transcript ending on a tool result renders as history with no trailing user turn", () => {
const messages: Message[] = [
user("open notes.md"),
assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }]),
toolResult("read", [{ type: "text", text: "# Notes" }]),
];
const expected = ["[user]\nopen notes.md", '[tool call read] {"path":"notes.md"}', "[tool result read]\n# Notes"].join("\n\n");
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
});
test("thinking blocks are omitted from replay", () => {
const messages: Message[] = [
user("hi"),
assistant([
{ type: "thinking", thinking: "secret plan" },
{ type: "text", text: "Hello." },
]),
user("bye"),
];
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: "[user]\nhi\n\n[assistant]\nHello.\n\n[user]\nbye" }]);
});
test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => {
assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), {
type: "user",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
});
});
+55
View File
@@ -0,0 +1,55 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { isSubscriptionModel, sumSteps, type TurnUsage } from "../src/core/index.ts";
import { graphCostSummary } from "../src/core/cost-summary.ts";
import { accumulateJobUsage } from "../src/core/contracts/jobs.ts";
import { callCost, jobCost, sessionCost } from "../src/server/pricing.ts";
import { toModelDto } from "../src/server/model-catalog.ts";
import { ModelMenuList } from "../components/settings/model-menu.tsx";
import { StatsBar } from "../components/stats-bar.tsx";
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 50, cacheWriteTokens: 0, reasoningTokens: undefined };
for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retained but dollars are unpriced, not free or subscription use`, () => {
const id = `${model.provider}:${model.id}`;
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", at: "2026-10-28T00:00:00Z", model: id, steps: [step] };
assert(!isSubscriptionModel(id));
assert.equal(callCost(step, id), null);
const pricing = sessionCost({ turns: [turn] });
assert.equal(pricing.unpriced, 1); assert.equal(pricing.subscription, undefined);
assert.equal(pricing.turnCosts[turn.id], null);
assert.deepEqual(sumSteps(turn.steps), { ...step, cacheWrite1hTokens: undefined, reasoningTokens: undefined });
assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null);
const html = renderToStaticMarkup(<StatsBar turns={[turn]} pricing={pricing} />);
assert(!html.includes("$0"));
assert.match(html, /data-testid="stats-cost">CLI<\/span>/);
const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1,
stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } },
}] }], sources: [] }, callCost);
assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined);
assert.equal(summary.runs[0]!.dollars, null);
});
test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => {
const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] };
const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" };
const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } };
const html = renderToStaticMarkup(<StatsBar turns={[api, cli]} pricing={pricing} />);
assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/);
const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" };
const mixed = renderToStaticMarkup(<StatsBar turns={[api, cli, subscription]} pricing={{ ...pricing, subscription: 1, subscriptionProviders: ["ChatGPT"] }} />);
assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/);
assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting");
});
test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => {
const models = CLAUDE_CODE_MODELS.map(toModelDto);
assert(models.every((model) => model.cost === null));
const html = renderToStaticMarkup(<ModelMenuList models={models} effectiveId={undefined} onPick={() => {}} />);
assert.equal((html.match(/>CLI billing<\/span>/g) ?? []).length, 3);
assert.match(html, /does not estimate it/);
assert(!html.includes("$0")); assert(!html.includes("USD per 1M")); assert(!html.includes("(subscription)"));
});
+63
View File
@@ -0,0 +1,63 @@
/** Real execFile boundary, authored fixture executables only. Never the installed CLI. */
import assert from "node:assert/strict";
import { type spawn } from "node:child_process";
import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
import { readClaudeVersion } from "../src/agent/claude-code/installation.ts";
import { createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const unsupportedShebang = process.platform === "win32" || /\s/.test(process.execPath);
function fixture(t: TestContext, mode: "normal" | "oversize" | "hang", name = "claude") {
const dir = mkdtempSync(join(tmpdir(), "lh-version-runner-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const bin = join(dir, "bin"); mkdirSync(bin);
const binary = join(bin, name);
const body = mode === "hang" ? `
import { writeFileSync } from "node:fs";
writeFileSync(process.argv[1] + ".pid", String(process.pid));
process.on("SIGTERM", () => {});
setInterval(() => {}, 1000);
` : mode === "oversize" ? 'process.stdout.write("x".repeat(100000));' : `
if (JSON.stringify(process.argv.slice(2)) !== '["--version"]') process.exit(2);
process.stdout.write("2.1.310 (Claude Code)\\n");
`;
writeFileSync(binary, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return { dir, binary };
}
test("production version runner directly executes a spaced/metacharacter filename with only --version", { skip: unsupportedShebang }, async (t) => {
const { binary } = fixture(t, "normal", "claude ; fixture");
assert.equal(await readClaudeVersion(binary), "2.1.310 (Claude Code)\n");
});
test("production version runner bounds stdout", { skip: unsupportedShebang }, async (t) => {
const { binary } = fixture(t, "oversize");
await assert.rejects(readClaudeVersion(binary), (error: NodeJS.ErrnoException) => error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
});
test("production version runner times out and SIGKILLs an uncooperative fixture", { skip: unsupportedShebang, timeout: 10000 }, async (t) => {
const { binary } = fixture(t, "hang");
const start = Date.now();
await assert.rejects(readClaudeVersion(binary), (error: Error & { signal?: string }) => error.signal === "SIGKILL");
assert(Date.now() - start < 8500, "bounded five-second timeout");
const pid = Number(readFileSync(`${binary}.pid`, "utf8"));
assert.throws(() => process.kill(pid, 0), /ESRCH/, "fixture no longer exists");
});
test("relative PATH is resolved at server cwd and stays the same for probe and model launch", { skip: unsupportedShebang }, async (t) => {
const { dir, binary } = fixture(t, "normal");
const found = resolveClaudeBinary({ env: { PATH: "./bin" }, homeDir: dir, platform: process.platform, cwd: dir,
isExecutable: (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } },
});
assert.equal(found, binary);
assert.equal(await readClaudeVersion(found!), "2.1.310 (Claude Code)\n", "probe changes cwd to tmpdir but executes the discovered file");
let spawned = false;
const launch = createClaudeLauncher({ findBinary: () => found,
spawnProcess: ((path: string) => { assert.equal(path, binary); spawned = true; return null; }) as unknown as typeof spawn,
});
await launch(["-p"], { cwd: tmpdir(), env: { NODE_ENV: "test" } });
assert(spawned, "same verified absolute identity passed to the model launcher");
});
+148
View File
@@ -0,0 +1,148 @@
/**
* Stand-in for the claude CLI in tests. Reads the stream-json input from
* stdin, then replays the scenario file named by FAKE_CLAUDE_SCENARIO line by
* line. Lines with a "fake" key are directives instead of output:
* {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call
* {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code
* {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result
* {"fake":"start-call",...} start the same request without waiting for its result
* {"fake":"cancel-call","id":"toolu_..."} cancel an individual pending MCP request
* {"fake":"checkpoint"} notify the parent after previous sends finish
* {"fake":"wait-for-message"} pause until the test releases the child over IPC
* {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation
* {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | "
* FAKE_CLAUDE_STDIN, when set, receives the stdin it read.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { on } from "node:events";
import { createInterface } from "node:readline";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const parentMessages = on(process, "message");
const HOLD_INTERVAL_MS = 60_000;
const BRIDGE_SERVER_NAME = "learnharness";
function writeLine(line) {
return new Promise((resolve) => process.stdout.write(`${line}\n`, resolve));
}
function streamEvent(event) {
return JSON.stringify({ type: "stream_event", event });
}
let lastSend = Promise.resolve();
async function connectBridge(mcpConfig) {
const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME];
const client = new Client({ name: "fake-claude", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
const send = transport.send.bind(transport);
transport.send = (...args) => (lastSend = send(...args));
await client.connect(transport);
return client;
}
async function writeReply(text) {
await writeLine(streamEvent({ type: "message_start", message: { usage: {} } }));
await writeLine(streamEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }));
await writeLine(streamEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }));
await writeLine(streamEvent({ type: "content_block_stop", index: 0 }));
await writeLine(streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} }));
await writeLine(streamEvent({ type: "message_stop" }));
}
const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8");
let input = "";
let receiveUser;
const userReceived = new Promise((resolve) => { receiveUser = resolve; });
createInterface({ input: process.stdin }).on("line", async (line) => {
input += `${line}\n`;
const message = JSON.parse(line);
if (message.type !== "control_request") {
if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input);
receiveUser(); return;
}
if (message.request.subtype === "initialize") {
if (process.env.FAKE_CLAUDE_INIT === "ignore") return;
await writeLine(JSON.stringify({ type: "control_response", response: {
request_id: message.request_id, subtype: process.env.FAKE_CLAUDE_INIT === "error" ? "error" : "success",
response: {}, error: "unsupported initialize",
} }));
} else if (message.request.subtype === "get_context_usage") {
await writeLine(JSON.stringify({ type: "control_response", response: {
request_id: message.request_id, subtype: "success",
response: { maxTokens: process.env.FAKE_CLAUDE_WINDOW ? JSON.parse(process.env.FAKE_CLAUDE_WINDOW) : null },
} }));
}
});
await userReceived;
let bridge = null;
const results = [];
const pendingResults = [];
const controllers = new Map();
const cancelled = new Set();
const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean);
for (const line of lines) {
const directive = JSON.parse(line);
if (directive.fake === "cancel-call") {
cancelled.add(directive.id);
controllers.get(directive.id)?.abort("cancelled by fake CLI");
await lastSend;
continue;
}
if (directive.fake === "checkpoint") {
await lastSend;
process.send?.({ fake: "checkpoint" });
continue;
}
if (directive.fake === "wait-for-message") {
await parentMessages.next();
continue;
}
if (directive.fake === "hold") {
setInterval(() => {}, HOLD_INTERVAL_MS);
break;
}
if (directive.fake === "exit") {
process.stderr.write(directive.stderr ?? "");
process.exit(directive.code);
}
if (directive.fake === "ignore-term") {
process.on("SIGTERM", () => {});
continue;
}
if (directive.fake === "call" || directive.fake === "start-call") {
bridge ??= await connectBridge(mcpConfig);
const controller = new AbortController();
controllers.set(directive.id, controller);
const request = bridge.callTool({
name: directive.name,
arguments: directive.arguments,
...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}),
}, undefined, { signal: controller.signal });
if (directive.fake === "start-call") {
pendingResults.push({ id: directive.id, request });
void request.catch(() => {});
await lastSend;
} else {
const result = await request;
results.push(result.content.map((block) => block.text).join(""));
}
continue;
}
if (directive.fake === "reply") {
for (const result of await Promise.all(pendingResults.splice(0).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join(""));
await writeReply(results.join(" | "));
continue;
}
await writeLine(line);
}
+4
View File
@@ -0,0 +1,4 @@
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"941c8a75-e5cc-4014-93ec-4b065e619d15"}
{"type":"system","subtype":"status","status":"requesting","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"1cbc259e-ad27-49ae-8a03-1dd0bc7d04ae"}
{"type":"assistant","message":{"diagnostics":null,"id":"a4eeead1-04bb-47fd-9567-bebc78052a3e","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null,"fallback_credit":null},"content":[{"type":"text","text":"Not logged in · Please run /login"}],"context_management":null},"parent_tool_use_id":null,"session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"58c2fe59-d155-4d2d-a0e1-f64e6aa09199","timestamp":"2026-10-24T14:03:24.319Z","error":"authentication_failed","is_api_error_message":true}
{"duration_api_ms":0,"stop_reason":"stop_sequence","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard","fallback_credit":null},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":true,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Not logged in · Please run /login","type":"result","duration_ms":21,"uuid":"7e41c29d-1912-4c14-8955-9f176fef725f","queued_turn_count":0,"result_index":0}
+11
View File
@@ -0,0 +1,11 @@
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"c7e564b7-af08-4024-a89e-66736710e2c1"}
{"type":"system","subtype":"status","status":"requesting","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"0738b666-b249-4cca-8c36-f92df8326546"}
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"512e3a21-2563-457b-addb-adade96b3d2b","ttft_ms":1224,"thinking_display":"updates","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"0b2a75fb-819f-43ef-9fc7-839f1a5c5a01","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello there"}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85823b9b-d5e0-4973-93f9-8525a8894762","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[{"type":"text","text":"hello there"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"92acba82-60ca-4502-a5a9-bafd75bd7277","timestamp":"2026-10-24T14:02:46.324Z","request_id":"req_011CgMBkZrxiAPxuioCwrRRu"}
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"1d19a483-51e6-45b6-96a2-bf445a9ab04b","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85ded13f-99fc-4ac2-b211-2c71a40bb817","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"d9085ac3-b00c-4e22-a17e-0fafcf7782a2","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"679f75df-0571-4b65-bc3e-c4cee96c7061","session_id":"a489e311-9d94-4990-b539-63a765dd0edd"}
{"duration_api_ms":1236,"stop_reason":"end_turn","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","total_cost_usd":0.0001241,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":607,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":607,"webSearchRequests":0,"costUSD":0.0001241,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"hello there","ttft_ms":1245,"type":"result","duration_ms":1258,"uuid":"faf44ee1-84e1-40eb-914e-654abb6c2286","ttft_stream_ms":1244,"time_to_request_ms":21,"first_content_frame_ms":1245,"queued_turn_count":0,"result_index":0}
+26
View File
@@ -0,0 +1,26 @@
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","model":"claude-haiku-5-5","tools":["mcp__learnharness__get_weather"],"mcp_servers":[{"name":"learnharness","status":"connected","source":"dynamic"}],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"9524e7cd-f580-4019-8e63-861e36aa190d"}
{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"6a55f520-5449-4845-bfe7-d3d74e4f5dda"}
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"16a84391-efcc-4221-9029-1fbea99edbab","ttft_ms":1170,"thinking_display":"updates","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{},"caller":{"type":"direct"}}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"460ff54c-2ab3-46a0-b2bf-c0d780d04e58","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"e73df731-5ecc-439c-811a-7b6cc5219a7f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1ae8e01b-8cfe-4282-a008-a9ef3f2e05fa","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"b7cf01be-dc67-463e-ad06-19267ea81cc3","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{"city":"Paris"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"da987ff6-9b30-4c1e-8f5b-20bda7386663","timestamp":"2026-10-24T14:03:09.115Z","request_id":"req_011CgMBnETxUPY4jwbRZ3rFy","wire_tool_inputs":{"toolu_01JBB4cJsPa585FWb1XrfbNX":{"city":"Paris"}},"tool_use_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","display_name":"Get Weather","server_display_name":"learnharness","tool_name":"get_weather"}]}
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"f438dbab-5bac-4b68-a237-ef9564abd25c","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JBB4cJsPa585FWb1XrfbNX","type":"tool_result","content":[{"type":"text","text":"Sunny, 21C in Paris"}]}]},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"f44480b5-8e02-4958-888e-fbbe3b54dc80","timestamp":"2026-10-24T14:03:09.124Z","tool_use_result":[{"type":"text","text":"Sunny, 21C in Paris"}],"tool_result_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","permission_decision":{"decision":"accept","source":"config","reason_type":"rule"}}]}
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"output_tokens":59,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":59,"cache_read_input_tokens":0,"cache_creation_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7fec4e77-f338-4f24-810c-d6fa959d154f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"5d3b3311-08d0-4af6-be82-b0d3f472e99d","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"f21e5357-4ffc-4346-b9fc-6543e910eab3","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038"}
{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"22297606-28e0-4b2f-9208-bc4be5487a80"}
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"90d16779-8e65-40d2-a55e-a68a9786a927","ttft_ms":1274,"thinking_display":"updates","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1f8d1832-7837-444e-a07e-2598927888bb","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"It"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"29baece7-f120-4088-8901-9ee4c16f9ffa","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s sunny in Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7189b574-320a-4730-9cf7-c92c089bdf95","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" right"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1db451a5-7027-4b25-bb69-2b8b6b94d600","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" now, with a temperature of"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7ca48472-b631-4117-a204-9f6fe7854f4a","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 21°C."}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"86930cda-4e2b-405c-a45f-29ee42d2efca","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[{"type":"text","text":"It's sunny in Paris right now, with a temperature of 21°C."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"69cad926-aa35-4a82-a24c-599cc5422d99","timestamp":"2026-10-24T14:03:10.497Z","request_id":"req_011CgMBnLc4EeExeJ32dkAoH"}
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"c05c7c31-f90a-4f4b-8a5b-31f2dae60294","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"output_tokens":25,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"6193aeb9-61b1-41e8-959a-9ae646429d11","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"22fbda30-76c1-4527-9819-cc3d00fb4e68","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"duration_api_ms":2827,"stop_reason":"end_turn","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","total_cost_usd":0.00029382000000000003,"usage":{"input_tokens":4,"cache_creation_input_tokens":1202,"cache_read_input_tokens":1102,"output_tokens":84,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1202,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":4,"outputTokens":84,"cacheReadInputTokens":1102,"cacheCreationInputTokens":1202,"webSearchRequests":0,"costUSD":0.00029382000000000003,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"It's sunny in Paris right now, with a temperature of 21°C.","ttft_ms":1457,"type":"result","duration_ms":2854,"uuid":"0be743a1-83e1-45ab-96f5-926ffee7a85b","ttft_stream_ms":1191,"time_to_request_ms":21,"first_content_frame_ms":1191,"queued_turn_count":0,"result_index":0}
+98 -9
View File
@@ -1,6 +1,13 @@
import { describe, it } from "node:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import assert from "node:assert/strict";
import {
const testData = fs.mkdtempSync(path.join(os.tmpdir(), "lh-cli-catalog-data-"));
process.env.LH_DATA_DIR = testData;
after(() => fs.rmSync(testData, { recursive: true, force: true }));
const {
catalogContextLimits,
catalogDefaultsFor,
catalogProviders,
catalogRolePins,
@@ -12,16 +19,18 @@ import {
toModelDto,
validateChatSettings,
validateModelsSettings,
} from "../src/server/model-catalog.ts";
} = await import("../src/server/model-catalog.ts");
import type { EffectiveSlot } from "../src/core/settings-resolution.ts";
import type { SettingsValidation } from "../src/server/model-catalog.ts";
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts";
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
const { piEngineForSpec, piModels } = await import("../src/agent/models.ts");
import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { readSettings } from "../src/store/settings.ts";
import { effectiveSettings } from "../src/server/stores.ts";
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
const { SETTINGS_FILE } = await import("../config.ts");
const { effectiveSettings } = await import("../src/server/stores.ts");
const { providerKeyPresent } = await import("../src/server/keys.ts");
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
// hold our projection + validation honest against the shipped catalog, and
@@ -59,8 +68,10 @@ describe("the model catalog projection", () => {
assert.equal(typeof m.id, "string");
assert.equal(typeof m.name, "string");
assert.ok(Array.isArray(m.supportedLevels));
assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number");
assert.ok(m.contextWindow > 0);
if (m.provider === "claude-code") assert.equal(m.cost, null);
else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number");
if (m.provider === "claude-code") assert.equal(m.contextWindow, null);
else assert.ok(m.contextWindow !== null && m.contextWindow > 0);
}
const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash");
assert.ok(glm, "glm-5.3-flash in catalog");
@@ -121,6 +132,7 @@ describe("the model catalog projection", () => {
assert.equal(dto.id, raw.id);
assert.equal(dto.name, raw.name);
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
assert.ok(dto.cost);
assert.equal(dto.cost.input, raw.cost.input);
assert.equal(dto.contextWindow, raw.contextWindow);
});
@@ -326,4 +338,81 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => {
const { piEngineFor } = await import("../src/agent/models.ts");
assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/);
});
});
});
/** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only
* finds a claude binary when installed is true. */
function withClaudeInstalled(installed: boolean, fn: () => void, enabled = true): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-"));
const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix };
const settings = readSettings();
writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled } });
if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
process.env.PATH = dir;
process.env.HOME = dir;
delete process.env.npm_config_prefix;
try {
fn();
} finally {
for (const [name, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
if (settings) writeSettings(settings);
else fs.rmSync(SETTINGS_FILE, { force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => {
it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => {
withClaudeInstalled(true, () => {
const ids = modelCatalog()
.models.filter((m) => m.provider === "claude-code")
.map((m) => m.id);
assert.deepEqual(ids, ["haiku", "opus", "sonnet"]);
});
});
it("catalogProviders() marks claude-code present when the CLI is installed", () => {
withClaudeInstalled(true, () => {
const row = catalogProviders().find((p) => p.id === "claude-code");
assert.deepEqual(row, { id: "claude-code", envVar: "", keyPresent: true });
});
});
it("catalogProviders() marks claude-code absent and modelCatalog() hides it when the CLI is missing", () => {
withClaudeInstalled(false, () => {
const row = catalogProviders().find((p) => p.id === "claude-code");
assert.equal(row?.keyPresent, false);
assert.equal(
modelCatalog().models.some((m) => m.provider === "claude-code"),
false,
);
});
});
it("catalogContextLimits() never advertises placeholder CLI limits, even when installed", () => {
withClaudeInstalled(true, () => {
assert.equal(catalogContextLimits().has("claude-code:opus"), false);
});
});
it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => {
const engine = piEngineForSpec({ provider: "claude-code", id: "sonnet" });
assert.equal(engine.model.provider, "claude-code");
assert.equal(engine.model.id, "sonnet");
});
it('providerKeyPresent("claude-code") requires both explicit opt-in and executable presence', () => {
withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true));
withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false));
withClaudeInstalled(true, () => {
assert.equal(providerKeyPresent("claude-code"), false);
assert.equal(catalogProviders().find((p) => p.id === "claude-code")?.keyPresent, false);
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
assert.equal(validateChatSettings({ model: { provider: "claude-code", id: "sonnet" } }).ok, false);
}, false);
});
});
+2 -2
View File
@@ -10,7 +10,7 @@ const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.i
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
test("API catalog models price through Pi at every tier boundary; native CLI stays unpriced and saved usage is never mutated", () => {
assert.ok(models.length > 100);
for (const model of models) {
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
@@ -18,7 +18,7 @@ test("all catalog models price through Pi, including every tier boundary; saved
const usage = nativeUsage(input);
const call = Object.freeze(saved(usage));
const before = structuredClone(call);
const expected = calculateCost(model, usage).total;
const expected = model.provider === "claude-code" ? null : calculateCost(model, usage).total;
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
assert.deepEqual(call, before);
}
+3
View File
@@ -88,6 +88,9 @@ describe("send_to_user model gate", () => {
["openrouter", "anthropic/claude-sonnet-5.5", true],
["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true],
["fixture", "CLAUDE-fixture", true],
["claude-code", "opus", true],
["claude-code", "sonnet", true],
["claude-code", "haiku", true],
["openrouter", "z-ai/glm-5.3-flash", false],
["openai", "gpt-4o-mini", false],
["anthropic", "opaque-alias", false],
+5
View File
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { externalCliEnv } from "../src/core/auth-env.ts";
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars
// (a developer shell may export REAL keys; tests use throwaway values only,
@@ -72,10 +73,12 @@ describe("boot fill-gap + precedence", () => {
assert.ok(row.shadowedByEnv, "file value differs from the env value");
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched");
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "external CLIs retain launch-env ownership");
});
it("unknown file var: fill-gapped into env, surfaced as a generic row", () => {
assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot");
assert.equal(externalCliEnv().FUTURE_PLANS_TOKEN, undefined, "external CLIs exclude unknown file-only injections too");
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
assert.equal(row.info, null, "no curated form");
assert.equal(row.source, "file");
@@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
const r = saveKey("DEEPGRAM_API_KEY", value);
assert.ok(r.ok);
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
assert.equal(externalCliEnv().DEEPGRAM_API_KEY, undefined);
assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated");
assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
assert.ok(!r.status.shadowedByEnv);
@@ -112,6 +116,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
assert.ok(r.ok);
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "saving a shadowed key cannot remove real env ownership");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
assert.ok(r.status.shadowedByEnv);
});
+1 -1
View File
@@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => {
// threshold is engine behavior, not an agent-section key.
assert.deepEqual(
Object.keys(SETTINGS_SECTIONS),
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
);
});
});