Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+351
View File
@@ -0,0 +1,351 @@
/** Offline integration contracts at the provider boundary: real shared image
* processing, faux model streams, synthetic rasters, and fake headless I/O.
* No real browser, provider, learner data, or ambient credentials are used. */
import assert from "node:assert/strict";
import * as fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, test } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import * as photon from "@silvia-odwyer/photon-node";
import {
createFauxCore,
fauxAssistantMessage,
fauxText,
fauxToolCall,
type FauxResponseStep,
type ImageContent,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import type { ModelImageResizeOptions } from "../src/agent/image-processing/index.ts";
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
// Install storage/auth seams BEFORE importing the agent composition root.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-agent-image-paths-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
fs.mkdirSync(path.join(scratch, "home"));
fs.writeFileSync(path.join(scratch, "auth.json"), "{}");
process.env = {
...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}),
...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}),
NODE_ENV: "test",
HOME: path.join(scratch, "home"),
LH_DATA_DIR: path.join(scratch, "data"),
LH_AUTH_FILE: path.join(scratch, "auth.json"),
};
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); };
after(() => {
process.env = originalEnv;
globalThis.fetch = originalFetch;
fs.rmSync(scratch, { recursive: true, force: true });
});
const { readTool } = await import("../src/agent/tools/read.ts");
const { createTeacherAgent, createProberAgent, createPracticeAgent } = await import("../src/agent/teacher-agent.ts");
const { runPdfReaderAgent } = await import("../src/agent/subagents/pdf-reader.ts");
const { runVisualMaker } = await import("../src/agent/subagents/visual-maker.ts");
const PDF = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="4800" height="120" viewBox="0 0 4800 120"><rect width="4800" height="120" fill="white"/></svg>';
const CUSTOM_RESIZE = { maxWidth: 320, maxHeight: 200 };
function png(width: number, height: number): Buffer {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = "#336699";
context.fillRect(0, 0, width, height);
return canvas.toBuffer("image/png");
}
function dimensions(image: ImageContent): { width: number; height: number } {
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(image.data, "base64"));
try {
return { width: decoded.get_width(), height: decoded.get_height() };
} finally { decoded.free(); }
}
function images(context: TranscriptContext): ImageContent[] {
return context.messages.flatMap((message) => Array.isArray(message.content)
? message.content.filter((part): part is ImageContent => part.type === "image")
: []);
}
function text(context: TranscriptContext): string {
return context.messages.map((message) => typeof message.content === "string"
? message.content
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")).join("\n");
}
function scripted(steps: FauxResponseStep[], resize?: ModelImageResizeOptions, vision = true) {
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
faux.setResponses(steps);
const base = faux.getModel();
const model: PiEngine["model"] = {
...base,
input: vision ? ["text", "image"] : ["text"],
...(resize ? { inputLimits: { ...base.inputLimits, images: { ...base.inputLimits?.images, resize } } } : {}),
};
const requests: TranscriptContext[] = [];
const engine: PiEngine = {
model,
streamFn: (model, context, options) => {
// Snapshot the actual provider-facing context, not mutable agent state.
requests.push(structuredClone(context));
return faux.streamSimple(model, context, options);
},
};
return { engine, requests };
}
function readSteps(file: string): FauxResponseStep[] {
return [
fauxAssistantMessage([fauxToolCall("read", { path: file }, { id: "read-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Inspected the supplied source.")]),
];
}
function fakeHeadless(rendered: Buffer | ((page: number) => Buffer)) {
const svgCalls: string[] = [];
const pdfCalls: number[][] = [];
const headless: HeadlessApi = {
available: () => true,
unavailableReason: () => null,
runDemo: async () => { throw new Error("unexpected browser execution"); },
renderSvg: async (svg) => {
svgCalls.push(svg);
return typeof rendered === "function" ? rendered(1) : rendered;
},
renderPdfPages: async (_file, pages) => {
pdfCalls.push([...pages]);
return pages.map((page) => ({ page, png: typeof rendered === "function" ? rendered(page) : rendered }));
},
};
return { headless, svgCalls, pdfCalls };
}
function assertProcessedImage(context: TranscriptContext, expected: { width: number; height: number }, raw: Buffer): void {
const parts = images(context);
assert.equal(parts.length, 1, "exactly one successful image reaches the faux provider");
assert.deepEqual(dimensions(parts[0]!), expected);
assert.ok(Buffer.byteLength(parts[0]!.data, "utf8") < 4.5 * 1024 * 1024);
assert.notEqual(parts[0]!.data, raw.toString("base64"), "the oversized raw raster is never sent");
assert.match(text(context), /\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by/);
}
test("shared teacher read detects valid image content under a non-image extension", async () => {
const bytes = png(24, 16);
const file = path.join(scratch, "lecture-notes.txt");
fs.writeFileSync(file, bytes);
const { engine, requests } = scripted(readSteps(file));
// Pass the actual shared read tool, exercising the teacher's native clone
// of read.execute rather than a pre-bound test-only implementation.
const agent = createTeacherAgent(undefined, { engine, tools: { read: readTool } });
await agent.prompt("Inspect the supplied local file.");
assert.equal(requests.length, 2);
const visible = requests[1]!;
assert.equal(images(visible).length, 1);
assert.equal(images(visible)[0]!.mimeType, "image/png");
assert.equal(images(visible)[0]!.data, bytes.toString("base64"));
assert.deepEqual(dimensions(images(visible)[0]!), { width: 24, height: 16 });
assert.match(text(visible), /Read image file \[image\/png\]/);
});
test("shared read omits corrupt recognized images without exposing a raw image fallback", async () => {
// Valid signature/IHDR, missing pixel data: sniff succeeds, decode fails.
const bytes = png(24, 16).subarray(0, 33);
const file = path.join(scratch, "corrupt.png");
fs.writeFileSync(file, bytes);
const { engine, requests } = scripted(readSteps(file));
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
assert.equal(requests.length, 2);
assert.deepEqual(images(requests[1]!), []);
assert.match(text(requests[1]!), /Image omitted: could not be resized below the inline image size limit/);
assert.ok(!JSON.stringify(requests[1]).includes(bytes.toString("base64")));
});
test("shared read supplies the native nonvision-model note", async () => {
const file = path.join(scratch, "nonvision.png");
fs.writeFileSync(file, png(24, 16));
const { engine, requests } = scripted(readSteps(file), undefined, false);
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
assert.equal(requests.length, 2);
assert.match(text(requests[1]!), /\[Current model does not support images\. The image will be omitted from this request\.\]/);
});
test("teacher read binds independent active-model resize limits without mutating the shared tool", async () => {
const bytes = png(640, 320);
const file = path.join(scratch, "model-bound.png");
fs.writeFileSync(file, bytes);
const originalExecute = readTool.execute;
const a = scripted(readSteps(file), { maxWidth: 96, maxHeight: 64 });
const b = scripted(readSteps(file), { maxWidth: 80, maxHeight: 30 });
const agentA = createTeacherAgent(undefined, { engine: a.engine, tools: { read: readTool } });
const agentB = createTeacherAgent(undefined, { engine: b.engine, tools: { read: readTool } });
await agentB.prompt("Inspect with model B.");
await agentA.prompt("Inspect with model A.");
assertProcessedImage(a.requests[1]!, { width: 96, height: 48 }, bytes);
assertProcessedImage(b.requests[1]!, { width: 60, height: 30 }, bytes);
assert.equal(readTool.execute, originalExecute);
});
for (const [role, resize, expected] of [
["prober", { maxWidth: 96, maxHeight: 64 }, { width: 96, height: 48 }],
["practice", { maxWidth: 80, maxHeight: 30 }, { width: 60, height: 30 }],
] as const) {
test(`${role}'s actual registry retains argument healing and text reads while binding image limits`, async () => {
const bytes = png(640, 320);
const imageFile = path.join(scratch, `${role}-actual-registry.png`);
const textFile = path.join(scratch, `${role}-actual-registry.txt`);
fs.writeFileSync(imageFile, bytes);
fs.writeFileSync(textFile, "first\nsecond\nthird\nfourth\nfifth");
const { engine, requests } = scripted([
fauxAssistantMessage([fauxToolCall("read", { path: imageFile }, { id: "registry-image" })], { stopReason: "toolUse" }),
// Exact existing heal contract: schema-integer fields encoded as
// JSON number strings heal before validation. The path stays intact.
fauxAssistantMessage([fauxToolCall("read", { path: textFile, offset: "2", limit: "2" }, { id: "registry-text" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Inspected the image and selected text lines.")]),
], resize);
const originalExecute = readTool.execute;
const forbidden = () => { throw new Error("unexpected job dispatch or handoff"); };
const chatStore = { getChat: () => null };
// Only context ports are mocked; no app/store imports or explicit
// tool overrides. Job/handoff operations must never execute here.
const agent = role === "prober"
? createProberAgent({
chatId: "offline-read-prober", chatStore,
jobStore: new Proxy({}, { get: forbidden }),
spawn: forbidden, deliver: forbidden,
} as never, { engine })
: createPracticeAgent({ chatId: "offline-read-practice", chatStore }, { engine });
const names = agent.state.tools.map((tool) => tool.name);
assert.ok(names.includes(role === "prober" ? "probe_done" : "problem"), "real role registry was assembled");
const boundRead = agent.state.tools.find((tool) => tool.name === "read");
assert.ok(boundRead?.prepareArguments, "model binding preserves the registry's prepareArguments wrapper");
assert.notEqual(boundRead.execute, originalExecute, "shared read execute is rebound to the active model");
assert.deepEqual(boundRead.prepareArguments({ path: textFile, offset: "2", limit: "2" }), { path: textFile, offset: 2, limit: 2 });
await agent.prompt("Inspect the local image, then read the selected text lines.");
assert.equal(requests.length, 3);
assertProcessedImage(requests[1]!, expected, bytes);
const selected = requests[2]!.messages.find((message) => message.role === "toolResult" && message.toolCallId === "registry-text");
assert.ok(selected && selected.role === "toolResult");
assert.equal(selected.isError, false);
assert.deepEqual(selected.content, [{ type: "text", text: "second\nthird\n\n[2 more lines in file. Use offset=4 to continue.]" }]);
assert.equal(readTool.execute, originalExecute, "role construction did not mutate the shared tool");
});
}
test("PDF reader preserves both processed image outputs from multiple render calls in one batch", async () => {
const wide = png(4800, 120);
const tall = png(120, 4800);
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? wide : tall);
const { engine, requests } = scripted([
fauxAssistantMessage([
fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "batch-render-1" }),
fauxToolCall("pdf_render", { pages: "2", dpi: 150 }, { id: "batch-render-2" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Both rendered pages are visible (p. 1, p. 2). Pages consulted: 1, 2")]),
]);
const result = await runPdfReaderAgent({ path: PDF, question: "Compare the two figures." }, { engine, headless });
assert.equal(requests.length, 2);
assert.deepEqual(pdfCalls, [[1], [2]]);
const visible = requests[1]!;
const parts = images(visible);
assert.equal(parts.length, 2, "the second render call must not overwrite the first call's images");
assert.deepEqual(parts.map(dimensions), [{ width: 2000, height: 50 }, { width: 50, height: 2000 }]);
for (let i = 0; i < parts.length; i++) {
assert.ok(parts[i]!.data.length < 4.5 * 1024 * 1024);
assert.notEqual(parts[i]!.data, [wide, tall][i]!.toString("base64"));
}
assert.deepEqual(visible.messages.slice(-3).map((message) => message.role), ["toolResult", "toolResult", "user"]);
const pageMessage = visible.messages.at(-1)!;
assert.ok(Array.isArray(pageMessage.content));
assert.deepEqual(pageMessage.content.filter((part) => part.type === "text").map((part) => part.text.split("\n")[0]), [
"The rendered page(s), in order (p1, p2):", "Page 1", "Page 2",
]);
assert.deepEqual(result.pagesRead, [1, 2]);
assert.deepEqual(result.pagesConsulted, [1, 2]);
});
for (const [label, resize, expected] of [
["native defaults", undefined, { width: 2000, height: 50 }],
["active-model overrides", CUSTOM_RESIZE, { width: 320, height: 8 }],
] as const) {
test(`PDF reader sends processed renders using ${label}; failed page is not counted`, async () => {
const bytes = png(4800, 120);
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? bytes : Buffer.from("invalid rendered page"));
const { engine, requests } = scripted([
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1-2", dpi: 150 }, { id: "render-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("The visible figure is on page 1 (p. 1). Pages consulted: 1")]),
], resize);
const result = await runPdfReaderAgent({ path: PDF, question: "Inspect the page figure." }, { engine, headless });
assert.equal(requests.length, 2);
assert.deepEqual(pdfCalls, [[1, 2]]);
assertProcessedImage(requests[1]!, expected, bytes);
assert.match(text(requests[1]!), /Page 2: \[Image omitted:/);
assert.deepEqual(result.pagesRead, [1]);
assert.deepEqual(result.pagesConsulted, [1]);
assert.match(result.brief, /visible figure/);
const visible = requests[1]!.messages;
assert.equal(visible.at(-2)!.role, "toolResult");
assert.equal(visible.at(-1)!.role, "user", "processed page images ride the user message after tool results");
});
for (const forced of [false, true]) {
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} path processes images using ${label}`, async () => {
const bytes = png(4800, 120);
const { headless, svgCalls } = fakeHeadless(bytes);
const steps: FauxResponseStep[] = forced ? [
fauxAssistantMessage([fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Premature notes without inspection.")]),
fauxAssistantMessage([fauxText("Confirmed after inspecting the final render.")]),
] : [
fauxAssistantMessage([
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
fauxToolCall("render_svg", {}, { id: "render-1" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Confirmed after inspecting the render.")]),
];
const { engine, requests } = scripted(steps, resize);
const result = await runVisualMaker({ brief: "One precise synthetic figure." }, { engine, headless });
assert.equal(requests.length, forced ? 3 : 2);
assert.deepEqual(svgCalls, [SVG]);
assertProcessedImage(requests.at(-1)!, expected, bytes);
assert.equal(result.svg, SVG);
assert.match(result.notes!, /^Confirmed after inspecting/);
assert.equal(requests.at(-1)!.messages.at(-1)!.role, "user");
if (forced) {
assert.deepEqual(images(requests[1]!), [], "premature finish did not see an image yet");
assert.match(text(requests[2]!), /You finished without looking at a render of your final SVG/);
} else {
assert.match(text(requests[1]!), /Here is the current render of your SVG/);
assert.equal(requests[1]!.messages.at(-2)!.role, "toolResult");
}
});
}
}
for (const forced of [false, true]) {
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} processing failure never verifies or delivers the SVG`, async () => {
const { headless, svgCalls } = fakeHeadless(Buffer.from("corrupt render"));
const steps: FauxResponseStep[] = [
fauxAssistantMessage([
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
...(!forced ? [fauxToolCall("render_svg", {}, { id: "render-1" })] : []),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Unverified claim that the picture is correct.")]),
];
const { engine, requests } = scripted(steps);
const activity: string[] = [];
const result = await runVisualMaker({ brief: "One synthetic figure." }, { engine, headless, onActivity: (line) => activity.push(line) });
assert.equal(requests.length, 2);
assert.ok(requests.every((request) => images(request).length === 0));
assert.equal(result.svg, "");
assert.match(result.notes!, /^FAILED: inspecting the final SVG failed/);
assert.match(result.notes!, /Image omitted:/);
assert.ok(!activity.some((line) => line.includes("inspecting")), "a failed image is never marked inspected");
assert.equal(svgCalls.length, forced ? 1 : 2, "an explicit processing failure leaves the final-SVG gate unsatisfied");
if (!forced) assert.match(text(requests[1]!), /RENDER ERROR.*Image omitted:/);
});
}
+301
View File
@@ -0,0 +1,301 @@
/** Actual shell functions with fake Electron/updater/filesystem. No app,
* profile, network, timer or updater download/install operation is run. */
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createContext, runInContext } from "node:vm";
import { describe, it } from "node:test";
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
const mainSource = readFileSync(mainFile, "utf8");
const mainRequire = createRequire(mainFile);
function fixture({ packaged = true, updaterMissing = false } = {}) {
let settings: string | Error = JSON.stringify({ version: 1, general: { autoUpdate: true } });
const settingsReads: string[] = [];
const order: string[] = [];
const intervals: { callback: () => void; delay: number }[] = [];
const handles = new Map<string, (event: unknown) => unknown>();
const windows: FakeWindow[] = [];
let enumerationFails = false;
let userData = "/fixture/custom-user-data";
let menu: { submenu?: { label?: string; click?: () => void }[] }[] = [];
class FakeUpdater extends EventEmitter {
checks = 0;
autoDownload = false;
autoInstallOnAppQuit = false;
result: object | null = { updateInfo: { version: "0.3.7" } };
failure: Error | null = null;
checkForUpdates() {
this.checks++;
this.emit("checking-for-update");
if (this.failure) return Promise.reject(this.failure);
if (this.result) this.emit("update-not-available", { version: "0.3.7" });
return Promise.resolve(this.result);
}
quitAndInstall() { order.push("install"); }
}
const updater = new FakeUpdater();
class FakeWebContents extends EventEmitter {
mainFrame = { url: "http://127.0.0.1:9000/" };
messages: { channel: string; value: unknown }[] = [];
loading = false;
fails = false;
send(channel: string, value: unknown) {
if (this.fails) throw new Error("window destroyed");
this.messages.push({ channel, value: JSON.parse(JSON.stringify(value)) });
}
isLoadingMainFrame() { return this.loading; }
setWindowOpenHandler() {}
getZoomFactor() { return 1; }
}
class FakeWindow extends EventEmitter {
webContents = new FakeWebContents();
minimized = false;
focused = false;
constructor() { super(); windows.push(this); }
loadURL() { this.webContents.loading = true; }
isMinimized() { return this.minimized; }
restore() { this.minimized = false; }
focus() { this.focused = true; }
show() {}
static getAllWindows() {
if (enumerationFails) throw new Error("window enumeration failed");
return windows;
}
static fromWebContents(contents: FakeWebContents) { return windows.find(win => win.webContents === contents); }
}
const app = Object.assign(new EventEmitter(), {
isPackaged: packaged,
name: "LearnHarness",
getPreferredSystemLanguages: () => ["en-US"],
getVersion: () => "0.6.0",
setName() {},
getPath: () => userData,
setPath: (_: string, value: string) => { userData = value; },
requestSingleInstanceLock: () => false,
quit() {},
whenReady: async () => {},
});
const electron = {
app, BrowserWindow: FakeWindow,
Menu: { buildFromTemplate: (value: typeof menu) => value, setApplicationMenu: (value: typeof menu) => { menu = value; } },
ipcMain: { on() {}, handle: (channel: string, fn: (event: unknown) => unknown) => handles.set(channel, fn) },
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = createContext({
require: (id: string) => {
if (id === "electron") return electron;
if (id === "electron-updater") {
if (updaterMissing) throw new Error("missing updater");
return { autoUpdater: updater };
}
if (id === "node:fs") return {
readFileSync: (file: string) => {
settingsReads.push(file);
if (settings instanceof Error) throw settings;
return settings;
}, mkdirSync() {}, appendFileSync() {},
};
return mainRequire(id);
},
process: { argv: [], env: { LH_DEV_URL: "http://127.0.0.1:9000" }, platform: "darwin", on() {} },
__dirname: path.dirname(mainFile), URL,
setInterval: (callback: () => void, delay: number) => intervals.push({ callback, delay }),
setTimeout() {},
fixtureChild: { kill: (signal: string) => order.push(signal) },
});
runInContext(mainSource, context, { filename: mainFile });
const run = (code: string) => runInContext(code, context);
return {
run, updater, windows, intervals, handles, settingsReads, order, app,
setSettings: (value: unknown) => { settings = JSON.stringify(value); },
setSettingsFailure: (value: string | Error) => { settings = value; },
setEnumerationFailure: (value: boolean) => { enumerationFails = value; },
window: () => new FakeWindow(),
state: () => JSON.parse(JSON.stringify(run("updateState"))),
check: async (manual = false) => { run(`checkForUpdates({ manual: ${manual} })`); await Promise.resolve(); await Promise.resolve(); },
menuCheck: () => menu[0]!.submenu!.find(item => item.label === "Check for Updates…")!.click!(),
};
}
describe("automatic-update preference gate", () => {
it("explicit OFF skips automatic checks, but manual checks still run without reading the preference", async () => {
const f = fixture();
f.setSettings({ general: { autoUpdate: false } });
f.run("setupAutoUpdater()");
await f.check();
assert.equal(f.updater.checks, 0);
assert.deepEqual(f.settingsReads, ["/fixture/custom-user-data-dev/data/settings.json"]);
await f.check(true);
assert.equal(f.updater.checks, 1);
assert.equal(f.settingsReads.length, 1);
assert.equal(f.updater.autoDownload, true);
assert.equal(f.updater.autoInstallOnAppQuit, true);
});
for (const [label, setting] of [
["ON", { general: { autoUpdate: true } }], ["absent field", { general: {} }],
["absent section", { version: 1 }], ["null", null], ["non-boolean", { general: { autoUpdate: "false" } }],
] as const) {
it(`${label} keeps automatic checks enabled`, async () => {
const f = fixture(); f.setSettings(setting); f.run("setupAutoUpdater()"); await f.check();
assert.equal(f.updater.checks, 1);
});
}
for (const [label, failure] of [["missing", new Error("ENOENT")], ["unreadable", new Error("EACCES")], ["corrupt", "{nope"]] as const) {
it(`${label} settings keep automatic checks enabled`, async () => {
const f = fixture(); f.setSettingsFailure(failure); f.run("setupAutoUpdater()"); await f.check();
assert.equal(f.updater.checks, 1);
});
}
it("re-reads the setting on each check, without triggering a check merely when saved", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.setSettings({ general: { autoUpdate: false } }); await f.check();
f.setSettings({ general: { autoUpdate: true } });
assert.equal(f.updater.checks, 0);
await f.check();
f.setSettings({ general: { autoUpdate: false } }); await f.check();
assert.equal(f.updater.checks, 1);
assert.equal(f.settingsReads.length, 3);
});
for (const label of ["unpackaged", "missing updater"] as const) {
it(`${label} stays unavailable, not a saved OFF preference`, async () => {
const f = fixture({ packaged: label !== "unpackaged", updaterMissing: label === "missing updater" });
f.run("setupAutoUpdater()"); await f.check(true); await f.check();
assert.equal(f.updater.checks, 0);
assert.equal(f.settingsReads.length, 0);
assert.equal(f.state().status, "disabled");
});
}
});
describe("status observation never controls the updater", () => {
it("reports events, but a staged update stays visible without suppressing future checks", async () => {
const f = fixture(); const win = f.window(); f.run("setupAutoUpdater()");
f.updater.emit("checking-for-update"); assert.equal(f.state().status, "checking");
f.updater.emit("update-available", { version: "0.3.8" });
assert.equal(f.state().status, "downloading"); assert.equal(f.state().version, "0.3.8");
f.updater.emit("update-downloaded", { version: "0.3.8" });
const ready = f.state();
await f.check();
f.updater.emit("error", new Error("failure with sensitive diagnostic details"));
assert.equal(f.updater.checks, 1);
assert.deepEqual(f.state(), ready);
f.updater.emit("update-downloaded", { version: "0.3.9" });
assert.equal(f.state().version, "0.3.9");
assert.ok(f.state().revision > ready.revision);
assert.equal(win.webContents.messages.at(-1)!.channel, "lh:update-status");
});
for (const status of ["checking", "downloading", "error", "disabled", "downloaded"]) {
it(`a ${status} display state does not block a future automatic check`, async () => {
const f = fixture(); f.run(`setupAutoUpdater(); reportUpdateStatus("${status}")`); await f.check();
assert.equal(f.updater.checks, 1);
});
}
it("check failures settle to a generic error and later checks still run", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.updater.failure = new Error("private endpoint/diagnostic detail"); await f.check();
assert.equal(f.state().status, "error");
assert.ok(!JSON.stringify(f.state()).includes("private"));
f.updater.failure = null; await f.check();
assert.equal(f.updater.checks, 2); assert.equal(f.state().status, "up-to-date");
});
it("a declined check makes no up-to-date claim", async () => {
const f = fixture(); f.updater.result = null; f.run("setupAutoUpdater()"); await f.check(true);
assert.equal(f.state().status, "idle");
});
it("window enumeration/send failures do not break checks or the next window's notification", async () => {
const f = fixture(); const bad = f.window(); bad.webContents.fails = true; const good = f.window();
f.run("setupAutoUpdater()"); await f.check();
assert.equal(good.webContents.messages.at(-1)!.channel, "lh:update-status");
f.setEnumerationFailure(true); await f.check();
f.setEnumerationFailure(false); await f.check();
assert.equal(f.updater.checks, 3);
});
it("OFF does not cancel in-flight events or alter staging/install-on-quit; Restart kills server first", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.updater.emit("update-available", { version: "0.3.8" });
f.setSettings({ general: { autoUpdate: false } });
f.updater.emit("update-downloaded", { version: "0.3.8" }); await f.check();
assert.equal(f.state().status, "downloaded"); assert.equal(f.updater.checks, 0);
assert.equal(f.updater.autoDownload, true); assert.equal(f.updater.autoInstallOnAppQuit, true);
f.run("serverProcess = fixtureChild; installUpdateNow()");
assert.deepEqual(f.order, ["SIGTERM", "install"]);
});
});
describe("startup, timer, trusted IPC and menu wiring", () => {
it("starts and retains the 24h timer without any Settings subscription, checking current preference each time", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } });
await f.run("main()");
assert.equal(f.updater.checks, 0);
assert.equal(f.intervals.length, 1); assert.equal(f.intervals[0]!.delay, 24 * 60 * 60 * 1000);
f.setSettings({ general: { autoUpdate: true } }); f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 1);
f.windows.length = 0; f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 2, "checks also work with every window closed");
});
it("ON/default boot checks immediately and status never disables the scheduled check", async () => {
const f = fixture(); f.setSettings({ version: 1 }); await f.run("main()");
assert.equal(f.updater.checks, 1);
f.run('reportUpdateStatus("downloading", "0.3.8")'); f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 2);
});
it("snapshot/manual IPC accepts only an owned main frame and snapshot reads never check", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
const sender = f.windows[0]!.webContents;
const event = { sender, senderFrame: sender.mainFrame };
const get = f.handles.get("lh:update:get")!; const check = f.handles.get("lh:update:check")!;
assert.equal((get(event) as { status: string }).status, "idle"); assert.equal(f.updater.checks, 0);
check(event); assert.equal(f.updater.checks, 1);
for (const bad of [
{ sender: {}, senderFrame: sender.mainFrame },
{ sender, senderFrame: { url: sender.mainFrame.url } },
]) {
assert.throws(() => get(bad), /Untrusted/); assert.throws(() => check(bad), /Untrusted/);
}
sender.mainFrame.url = "https://untrusted.invalid/";
assert.throws(() => check(event), /Untrusted/);
assert.equal(f.updater.checks, 1);
});
it("menu restores/focuses a minimized window, opens General, and manually checks while OFF", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
const win = f.windows[0]!; win.webContents.loading = false; win.minimized = true;
f.menuCheck();
assert.equal(win.minimized, false); assert.equal(win.focused, true);
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings" && message.value === "general"));
assert.equal(f.updater.checks, 1);
});
it("menu reopens a window and delivers General after load; loading existing windows are covered too", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
f.windows.length = 0; f.menuCheck();
assert.equal(f.windows.length, 1); assert.equal(f.updater.checks, 1);
const win = f.windows[0]!;
assert.ok(!win.webContents.messages.some(message => message.channel === "lh:open-settings"));
win.webContents.emit("did-finish-load");
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings"));
f.menuCheck(); win.webContents.emit("did-finish-load");
assert.equal(win.webContents.messages.filter(message => message.channel === "lh:open-settings").length, 2);
});
it("menu navigation failure does not prevent its manual check", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
f.windows[0]!.webContents.fails = true; f.windows[0]!.webContents.loading = false;
f.menuCheck(); assert.equal(f.updater.checks, 1);
});
});
+66 -5
View File
@@ -1,6 +1,9 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx";
import { parseModelsRefusal, parseProviderKeyRefusal, retryFailedResponse } from "../components/chat.tsx";
import { SessionModel } from "../components/session-core.ts";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { newUserMessage, type LhMessage } from "../src/core/native-messages.ts";
import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx";
import {
isModelsRefusal,
@@ -39,6 +42,64 @@ describe("parseModelsRefusal — the send refusal detector", () => {
});
});
describe("Retry owns no composer recovery draft", () => {
for (const envelope of [providerKeyMissing("openrouter", ["strong"]), modelsNotConfigured(["strong"])]) {
it(`accepted A / failed model / unsent B / Retry ${envelope.code} retires only A's fallback`, async () => {
const durable: LhMessage[] = [
newUserMessage("Accepted text A"),
{ ...fauxAssistantMessage("Partial reply", { stopReason: "error", errorMessage: "Model failed" }), id: "failed-A" },
];
const submittedDraft: { current: string | null } = { current: "Accepted text A" };
let composer = "Unsent text B";
const body = JSON.stringify(envelope);
const model = new SessionModel({
chatId: "fixture", initialMessages: durable,
fetch: async (_url, init) => {
assert.equal(submittedDraft.current, null, "retire A before Retry can reach a refusal");
assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" });
return new Response(body, { status: 503 });
},
});
let request!: ReturnType<SessionModel["send"]>;
retryFailedResponse((input) => request = model.send(input), submittedDraft);
await request;
assert.equal(model.getState().error?.message, body, "503 stays raw for existing setup/picker routing");
assert.ok(parseModelsRefusal(body) || parseProviderKeyRefusal(body));
// The existing refusal effect restores only an owned submitted
// draft. Retry must leave this slot empty, not replace B with A.
if (submittedDraft.current !== null) composer = submittedDraft.current;
assert.equal(composer, "Unsent text B");
assert.deepEqual(model.getState().nativeMessages, durable, "Retry adds no optimistic learner message");
});
}
it("does not clear a later ordinary send's recovery draft when Retry settles", async () => {
const submittedDraft: { current: string | null } = { current: "Accepted text A" };
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
retryFailedResponse(async () => pending, submittedDraft);
assert.equal(submittedDraft.current, null);
submittedDraft.current = "Later ordinary text C";
finish();
await pending;
await Promise.resolve();
assert.equal(submittedDraft.current, "Later ordinary text C", "no asynchronous cleanup may retire a newer owner");
});
it("ordinary text refusals still keep their submitted draft for recovery", async () => {
const submittedDraft: { current: string | null } = { current: "Ordinary text C" };
const body = JSON.stringify(providerKeyMissing("openrouter", ["strong"]));
const model = new SessionModel({
chatId: "fixture", initialMessages: [],
fetch: async () => new Response(body, { status: 503 }),
});
await model.send({ type: "text", text: submittedDraft.current! });
assert.equal(submittedDraft.current, "Ordinary text C");
assert.equal(model.getState().error?.message, body);
assert.equal(parseProviderKeyRefusal(model.getState().error?.message), true);
});
});
describe("isModelsRefusal — the job-record matcher", () => {
it("matches the runner's composed wording, not real failures", () => {
assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true);
@@ -123,9 +184,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
assert.deepEqual(pre, {});
});
it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => {
it("a disconnected saved identity still prefills without selectable metadata", () => {
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
assert.deepEqual(pre, {});
assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } });
});
it("provider collisions never cross-pollute (same id, different provider)", () => {
@@ -158,9 +219,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", (
});
});
describe("setupStage — unchanged by the BYOK wave", () => {
describe("setupStage — unknown is not keyless", () => {
it("keys first (owner ordering), pickers when a provider is keyed", () => {
assert.equal(setupStage(null), "keys");
assert.equal(setupStage(null), "loading");
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
});
+104 -4
View File
@@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts";
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
import { effectiveSettings } from "../src/server/stores.ts";
import { isTurnRunning } from "../src/server/turn-liveness.ts";
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { deliverJobResult, registerLiveAgent, setTurnRunner, steerLiveChat, unregisterLiveAgent } from "../src/server/jobs.ts";
import { SessionModel } from "../components/session-core.ts";
import { buildSubagentSteer } from "../src/core/contracts.ts";
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult, newUserMessage } from "../src/core/native-messages.ts";
import { projectView } from "../src/core/view.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
@@ -46,6 +47,8 @@ import type { LhMessage } from "../src/core/native-messages.ts";
// store imports already resolved under).
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
const steerRoute = await import("../app/api/chats/[id]/steer/route.ts");
const streamRoute = await import("../app/api/chats/[id]/stream/route.ts");
const recordRoute = await import("../app/api/chats/[id]/route.ts");
import { PATHS } from "../config.ts";
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
@@ -519,7 +522,7 @@ describe("teacher chat flow (scripted model)", () => {
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" });
jobIdMid = job.id;
// The runner stores the result BEFORE delivery (finalizeJob reads it).
jobStore.updateJob(job.id, { result: { brief: "Mid-run brief.", sources: [] } });
jobStore.updateJob(job.id, { status: "done", result: { brief: "Mid-run brief.", sources: [] }, delivery: { kind: "result", status: "pending" } });
const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] });
const delivery = deliverJobResult(steer);
await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off
@@ -531,7 +534,7 @@ describe("teacher chat flow (scripted model)", () => {
release2();
await drain(await res);
await delivery; // resolves at settleSteers (the run persisted the marker)
assert.equal(jobFlippedMidTurn, "done", "the job flipped at the marker's message_end — WHILE the turn still streamed");
assert.equal(jobFlippedMidTurn, "done", "worker completion is independent of the streaming teacher turn");
// Record shape: the marker sits BETWEEN completed turn units — after
// the toolResult it must never split, adjacent to it.
@@ -845,6 +848,103 @@ describe("chat store", () => {
});
});
describe("canonical persistence failures", () => {
it("refuses a failed input save without building an agent or leaving a live turn", async () => {
const { engine } = scriptedTeacher(defaultLesson);
const chat = createConfiguredChat("Test: failed answer write-ahead");
createdChats.push(chat.id);
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
const before = chatStore.getChat(chat.id)!.messages;
const save = chatStore.saveMessages;
let builds = 0;
chatStore.saveMessages = () => { throw new Error("synthetic write-ahead failure"); };
try {
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), (opts) => {
builds++;
return agentFactory(engine)!(opts);
});
const running = runningTurn(chat.id);
if (running) await new Response(running.subscribe()).text();
assert.equal(res.status, 500);
const client = new SessionModel({ chatId: chat.id, initialMessages: before, fetch: async () => res.clone() });
await client.submitAnswer("quiz", "call-1", gradedAnswer(QUIZ_INPUT, ["Mercury"]));
assert.equal(client.getState().error?.message, "Your input could not be saved. Please try again.");
assert.equal(await res.text(), "Your input could not be saved. Please try again.");
assert.equal(builds, 0);
assert.equal(isTurnRunning(chat.id), false);
assert.equal(runningTurn(chat.id), undefined);
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before);
} finally { chatStore.saveMessages = save; }
});
it("reports a failed reply save before DONE while keeping the accepted answer durable", async () => {
const { engine } = scriptedTeacher(defaultLesson);
const chat = createConfiguredChat("Test: failed reply persistence");
createdChats.push(chat.id);
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
const save = chatStore.saveMessages;
let writes = 0;
chatStore.saveMessages = (id, messages) => {
if (++writes === 2) throw new Error("synthetic reply save failure");
return save(id, messages);
};
try {
const body = await drainTurn(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine));
const errors = eventsOf(body).filter(e => e.type === "error");
assert.equal(errors.length, 1);
assert.equal(errors[0]!.errorText, "The reply could not be saved.");
assert.ok(body.indexOf("The reply could not be saved.") < body.indexOf("[DONE]"));
assert.equal(isTurnRunning(chat.id), false);
assert.equal(runningTurn(chat.id), undefined);
assert.equal(chatStore.getChat(chat.id)!.messages.at(-1)?.role, "toolResult");
// The normal submitter may not reach GET /stream until AFTER the
// failing turn has left the live registry. It must still see it.
const late = await streamRoute.GET(new Request("http://test/stream"), { params: Promise.resolve({ id: chat.id }) });
assert.equal(eventsOf(await late.text()).find(e => e.type === "error")?.errorText, "The reply could not be saved.");
// An already-open idle view can miss both the start and the wire
// error, then learn of settlement only via a record synchronization.
const snapshot = await (await recordRoute.GET(new Request("http://test/record"), { params: Promise.resolve({ id: chat.id }) })).json();
assert.equal(needsContinuation(snapshot.messages), true, "the durable answer alone looks like an owed continuation");
let automaticPosts = 0;
const missedView = new SessionModel({ chatId: chat.id, initialMessages: [], fetch: async () => {
automaticPosts++;
return new Response("unexpected automatic retry", { status: 500 });
} });
missedView.setMessages(snapshot.messages, snapshot.turnError);
await missedView.continueIfOwed(snapshot.messages);
assert.equal(automaticPosts, 0, "the record notice must block replacement even without a stream attachment");
assert.equal(missedView.getState().error?.message, "The reply could not be saved.");
} finally { chatStore.saveMessages = save; }
});
it("does not redeliver a persisted learner steer after usage bookkeeping fails", async () => {
const { engine } = scriptedTeacher(() => [() => fauxAssistantMessage([fauxText("Synthetic response")])]);
const chat = createConfiguredChat("Test: post-save bookkeeping failure");
createdChats.push(chat.id);
const steer = newUserMessage("Synthetic learner steer");
const appendUsage = chatStore.appendTurnUsage;
chatStore.appendTurnUsage = () => { throw new Error("synthetic usage write failure"); };
try {
const body = await drainTurn(post(chat.id, { type: "text", text: "go" }), opts => {
const agent = createTeacherAgent(undefined, { engine, messages: opts.messages, onUsage: opts.onUsage });
agent.subscribe(event => {
if (event.type === "agent_start") assert.equal(steerLiveChat(chat.id, steer), true);
});
return agent;
});
assert.equal(eventsOf(body).find(e => e.type === "error")?.errorText, "An error occurred.");
assert.ok(chatStore.getChat(chat.id)!.messages.some(m => m.id === steer.id), "the steer was saved before bookkeeping failed");
const delivered: LhMessage[] = [];
const next = { steer: (message: LhMessage) => delivered.push(message), state: { model: { provider: "faux" } } } as unknown as Parameters<typeof registerLiveAgent>[1];
registerLiveAgent(chat.id, next);
assert.deepEqual(delivered, [], "an already-saved learner steer must not reach another agent");
} finally {
chatStore.appendTurnUsage = appendUsage;
unregisterLiveAgent(chat.id);
}
});
});
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
const { engine } = scriptedTeacher(defaultLesson);
+75 -23
View File
@@ -5,8 +5,9 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import type { ChatGPTStatus } from "../src/core/contracts/chatgpt.ts";
import { completedWithin } from "./completion.ts";
const require = createRequire(import.meta.url);
const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
const { privateKey, publicKey } = await generateKeyPair("RS256");
@@ -56,14 +57,6 @@ function fixture(options: { state?: any; expires?: number; timeoutMs?: number; t
return fetch(callback);
} };
}
// Callback HTTP success precedes asynchronous token verification and persistence.
async function waitForSignIn(service: { status(): Promise<{ pending: boolean }> }) {
const deadline = Date.now() + 5000;
while ((await service.status()).pending) {
assert(Date.now() < deadline, "sign-in did not settle within the fixture timeout");
await delay(5);
}
}
async function loggedIn(options: Parameters<typeof fixture>[0] = {}) {
const f = fixture(options);
await f.service.login();
@@ -454,20 +447,50 @@ test("scope-disabled credentials remain revocable and unconfirmed revoke never c
} finally { f.service.close(); }
});
test("repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting", async () => {
const f = await loggedIn();
for (const holdVerification of [false, true]) test(`repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting${holdVerification ? " (held JWKS regression)" : ""}`, { timeout: 10000 }, async () => {
const repeatComplete = deferred<any>();
const verificationEntered = deferred(), verificationRelease = deferred();
let repeatArmed = false, sawPending = false, completed = false;
const f = await loggedIn({
change: (push) => {
if (!repeatArmed) return;
if (push.status.pending) sawPending = true;
else if (sawPending) { completed = true; repeatComplete.resolve(push.status); }
},
provider: async (url) => {
if (holdVerification && repeatArmed && url.endsWith("jwks.json")) {
verificationEntered.resolve(); await verificationRelease.promise;
}
return undefined;
},
});
try {
const original = f.saved.registrations[0];
await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" });
await waitForSignIn(f.service);
const status = await f.service.status();
const writesBeforeRepeat = f.writeCount;
// Initial login already settled fixture.changed; arm for this attempt's terminal notification.
repeatArmed = true;
await f.service.login(); assert.equal(sawPending, true);
assert.equal((await f.callback({ client_id: "oaiapp_repeat" })).status, 200);
if (holdVerification) {
await verificationEntered.promise;
assert.equal(completed, false, "HTTP acknowledgement cannot complete pending identity verification");
const held = await f.service.status();
assert.equal(held.pending, true); assert.equal(held.error, null);
assert.equal(held.activeId, original.id); assert.equal(held.accounts.length, 1);
assert.deepEqual(f.saved.registrations, [original]);
assert.equal(f.writeCount, writesBeforeRepeat, "unverified repeat sign-in cannot persist credentials");
}
verificationRelease.resolve();
const status = await repeatComplete.promise;
assert.equal(status.pending, false); assert.equal(status.error, null);
assert.equal(f.writeCount, writesBeforeRepeat + 1, "repeat sign-in completes after durable credential persistence");
assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1);
assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id);
assert.equal(f.saved.registrations[0].clientId, original.clientId); assert.equal(f.saved.registrations[1].clientId, "oaiapp_repeat");
await f.service.disconnect(status.activeId);
assert.deepEqual(f.saved.registrations, []); assert.equal((await f.service.status()).accounts.length, 0);
assert.equal(f.requests.filter((r) => r.form?.has("token_type_hint")).length, 2);
} finally { f.service.close(); }
} finally { verificationRelease.resolve(); f.service.close(); }
});
test("verified identity grouping hides legacy duplicate registrations without merging client credentials or matching emails", async () => {
@@ -590,10 +613,17 @@ test("disconnect persistence failure is not reported as local forgetting and lea
} finally { f.service.close(); }
});
test("one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations", async () => {
for (const holdToken of [false, true]) test(`one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations${holdToken ? " (completion held beyond old wait budget)" : ""}`, async () => {
const first = await loggedIn(); first.service.close();
const legacy = structuredClone(first.saved); legacy.registrations[0].background = false;
const f = fixture({ state: legacy });
const tokenEntered = deferred(), releaseToken = deferred();
let completion = deferred<ChatGPTStatus>();
const change = ({ status }: { status: ChatGPTStatus }) => { if (!status.pending) completion.resolve(status); };
const f = fixture({ state: legacy, change, token: async (form, url) => {
tokenEntered.resolve();
if (holdToken) await releaseToken.promise;
return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
} });
try {
assert.equal((await f.service.status()).allowBackgroundUse, true, "absent new setting ignores legacy per-account OFF");
await f.service.lease(true);
@@ -603,21 +633,43 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w
await f.service.lease(false);
const id = f.saved.activeId;
await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false);
await f.service.login(id); await f.callback();
await waitForSignIn(f.service);
// A fresh signal per attempt: the fixture's original changed promise
// was already resolved by earlier account/consent notifications.
completion = deferred<ChatGPTStatus>();
await f.service.login(id);
const response = await f.callback(); assert.equal(response.status, 200); await response.text();
const waiting = completedWithin(completion.promise, "returning verified sign-in");
let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; });
if (holdToken) {
await completedWithin(tokenEntered.promise, "held token request");
// Regression only: exhaust the OLD wait while real completion is
// explicitly held, rather than relying on machine speed or sleeps.
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
assert.equal((await f.service.status()).pending, true);
assert.equal(f.saved.registrations[0].background, false, "the old assertion would run before replacement was saved");
assert.equal(settled, false, "the corrected wait must still await completion");
releaseToken.resolve();
}
const status = await waiting;
assert.equal(status.pending, false); assert.equal(status.error, null, "reconnecting must actually succeed");
assert.equal(status.activeId, id); assert.equal(status.accounts[0]!.connected, true);
assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference");
assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference");
await f.service.disconnect(f.saved.activeId);
assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.allowBackgroundUse, false);
const restarted = fixture({ state: f.saved });
const restarted = fixture({ state: f.saved, change });
try {
assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF");
await restarted.service.login(); await restarted.callback();
await waitForSignIn(restarted.service);
completion = deferred<ChatGPTStatus>();
await restarted.service.login();
const response = await restarted.callback(); assert.equal(response.status, 200); await response.text();
const status = await completedWithin(completion.promise, "verified sign-in after zero-account restart");
assert.equal(status.pending, false); assert.equal(status.error, null);
assert.notEqual(status.activeId, null); assert.equal(status.accounts[0]!.connected, true);
assert.equal(restarted.saved.allowBackgroundUse, false);
await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true);
} finally { restarted.service.close(); }
} finally { f.service.close(); }
} finally { releaseToken.resolve(); f.service.close(); }
});
test("private leases bind active account, authority revision and raw visible model membership", async () => {
+3 -2
View File
@@ -43,7 +43,7 @@ for (const revokedAfterQueue of [false, true]) {
stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
const result = { brief: "Scratch completed job result", sources: [] };
stores.jobStore.updateJob(job.id, { status: "running", result });
stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } });
const marker = buildSubagentSteer(job, result);
const contexts: string[] = [];
const native = piModels().getModel("openai", "gpt-5")!;
@@ -83,7 +83,8 @@ for (const revokedAfterQueue of [false, true]) {
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(wakes, 0, "no background spin or spend while consent is absent");
assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral");
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending", "delivery ownership must not be finalized or failed by deferral");
assert.equal(stores.jobStore.getJob(job.id)!.status, "done", "worker completion is independent of consent deferral");
assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
connection(true);
await delivered;
+43 -9
View File
@@ -12,6 +12,7 @@ import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx";
import { KeysSection } from "../components/settings/keys-section.tsx";
import { ModelControl } from "../components/model-picker.tsx";
import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts";
import { completedWithin } from "./completion.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, localStorage: { getItem: () => null, setItem() {} } });
@@ -23,7 +24,7 @@ const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnect
const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.";
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
function deferred<T>() { let resolve!: (v: T) => void; let reject!: (error: unknown) => void; const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; }
function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
let status = initial;
const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 };
@@ -76,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"]'));
@@ -494,26 +497,57 @@ test("canceled attempt's late reply cannot release a newer pending attempt or ap
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => {
for (const holdSave of [false, true]) test(`real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence${holdSave ? " (completion held beyond old wait budget)" : ""}`, async () => {
const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" };
let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>();
const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => {
const consentCompleted = deferred<ChatGPTStatus>(), saveEntered = deferred<void>(), releaseSave = deferred<void>();
let saved: any = null, authorize!: URL, tokenCalls = 0, completionArmed = false; const listeners = new Set<() => void>();
const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) {
if (holdSave && s.allowBackgroundUse === false) { saveEntered.resolve(); await releaseSave.promise; }
saved = structuredClone(s);
} }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: ({ status }: { status: ChatGPTStatus }) => {
if (completionArmed && !status.pending && status.error) consentCompleted.reject(new Error(`Fixture sign-in failed: ${status.error}`));
for (const cb of listeners) cb();
}, fetch: async (url: string, init: RequestInit) => {
if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++;
const form = init.body as URLSearchParams;
const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey);
return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
} });
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => {
const reply = chatGPTControl({ op: "consent", enabled }, service);
void reply.then(consentCompleted.resolve, consentCompleted.reject);
return reply;
}, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
try {
await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0);
await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString();
await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); });
await act(async () => {
completionArmed = true;
// Callback acknowledgment is not verified login, nor is login completion
// the subsequent consent save. Wait for the actual control reply.
const waiting = completedWithin(consentCompleted.promise, "verified sign-in and global draft persistence");
let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; });
const response = await originalFetch(callback); assert.equal(response.status, 200); await response.text();
if (holdSave) {
await completedWithin(saveEntered.promise, "held global draft save");
// Regression only: the OLD loop returns with the save still held.
for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r));
assert.equal((await service.status()).pending, false, "verification finished before the consent save");
assert.equal(saved.allowBackgroundUse, undefined, "the old assertion would run before the global draft was saved");
assert.equal(settled, false, "the corrected wait must still await persistence");
releaseSave.resolve();
}
const status = await waiting;
assert.equal(status.error, null); assert.equal(status.pending, false);
assert.equal(status.allowBackgroundUse, false); assert.equal(status.accounts[0]!.connected, true);
});
assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined);
assert.equal(document.querySelector('[role="alert"]'), null);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…"));
} finally { await act(async () => service.close()); }
} finally { releaseSave.resolve(); await act(async () => service.close()); }
});
test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => {
+6 -2
View File
@@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts");
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
test("Next-style IPC is not desktop transport; web ownership works without delaying keyed catalogs", async () => {
const send = Object.getOwnPropertyDescriptor(process, "send");
const connected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
@@ -35,7 +35,11 @@ test("Next-style connected IPC is not desktop transport and never delays keyed A
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
assert.equal(chatGPTConnected(), false);
assert.deepEqual(chatGPTModels(), []);
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
if (!mode && !capability) {
const value = await chatGPTRequest<{ status: { available: boolean; accounts: unknown[] } }>("status");
assert.equal(value.status.available, true);
assert.deepEqual(value.status.accounts, []);
} else await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
}
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
+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");
});
+111 -1
View File
@@ -27,12 +27,14 @@ const {
ensureCompacted,
kickCompaction,
awaitCompaction,
compactionInFlight,
effectiveThresholdTokens,
projectedContextTokens,
} = await import("../src/server/compaction.ts");
import type { CompactionDeps } from "../src/server/compaction.ts";
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
const { subscribe } = await import("../src/core/events.ts");
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
import type { LhMessage } from "../src/core/index.ts";
after(() => {
@@ -167,12 +169,120 @@ describe("the compaction service", () => {
await ensureCompacted(deps, chatId);
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once");
const record = chatStore.getChat(chatId)!;
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
});
it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => {
const chat = chatStore.createChat("repeated-cycle fixture");
chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8)));
let calls = 0;
const deps: CompactionDeps = {
...depsOf(chat.id, 150_000),
summarize: async (prompt) => {
assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1);
if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward");
return {
summary: `SUMMARY-${++calls}.`,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
},
};
for (let cycle = 1; cycle <= 10; cycle++) {
const messages = [...chatStore.getChat(chat.id)!.messages];
if (cycle > 1) messages.push(big(`growth-${cycle}`, 8));
// Controlled provider readings trigger compaction while the visible
// retained tail advances slowly enough to contain older markers.
messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage);
chatStore.saveMessages(chat.id, messages);
await ensureCompacted(deps, chat.id);
assert.equal(calls, cycle);
const record = chatStore.getChat(chat.id)!;
assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored");
assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle);
const wire = JSON.stringify(toLlmMessages(record.messages));
assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`);
assert.ok(wire.includes(`SUMMARY-${cycle}.`));
assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings");
}
});
it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => {
const chat = chatStore.createChat("summary span fixture");
const kept = big("previously retained");
const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" };
const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 };
const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")];
chatStore.saveMessages(chat.id, messages);
let seenPrompt = "";
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
seenPrompt = prompt;
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
} }, chat.id);
assert.ok(seenPrompt.includes("previously retained"));
assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span");
assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY"));
assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1);
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
});
it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => {
const chat = chatStore.createChat("legacy summary fixture");
const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" });
const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")];
chatStore.saveMessages(chat.id, messages);
let seenPrompt = "";
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
seenPrompt = prompt;
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
} }, chat.id);
assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1);
assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff");
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
});
it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => {
const chatId = overThresholdChat("incomplete summary fixture");
await ensureCompacted(depsOf(chatId, 40_000), chatId);
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]);
const faux = createFauxCore({});
const { summarize: _injected, ...base } = depsOf(chatId, 40_000);
const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } };
const before = chatStore.getChat(chatId)!;
assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too");
const events: string[] = [];
const off = subscribe((event) => {
const { chatId: id, running } = event as { chatId?: string; running?: boolean };
if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`);
});
// Exercise the actual zero-tool summarizer with an offline provider;
// NODE_ENV=test otherwise deliberately skips non-injected summaries.
const env: Record<string, string | undefined> = process.env;
const originalNodeEnv = env.NODE_ENV;
env.NODE_ENV = "development";
try {
faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]);
await ensureCompacted(deps, chatId);
assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint");
assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate");
assert.deepEqual(events, ["true", "false"]);
faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]);
await ensureCompacted(deps, chatId);
const record = chatStore.getChat(chatId)!;
assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY"));
assert.deepEqual(record.messages.slice(0, -1), before.messages);
assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY"));
} finally {
if (originalNodeEnv === undefined) delete env.NODE_ENV;
else env.NODE_ENV = originalNodeEnv;
off();
}
});
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
+44
View File
@@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => {
assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once");
});
it("omits nine superseded summaries inside the retained tail without changing stored history", () => {
const kept = newUserMessage("retained exchange");
const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" };
const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details));
const fresh = newCompactionMarker("LATEST SUMMARY", details);
const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh];
const original = JSON.stringify(messages);
const out = toLlmMessages(messages);
const flat = JSON.stringify(out);
assert.equal(out.length, 3, "latest summary + retained exchange + latest reply");
assert.ok(flat.includes("LATEST SUMMARY"));
assert.ok(!flat.includes("SUPERSEDED-"));
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
assert.equal(JSON.stringify(messages), original, "projection never rewrites the record");
});
it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => {
const kept = [assistantWithCall("a1", "call_1")];
const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" });
@@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => {
assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)");
});
it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => {
const kept = newUserMessage("retained exchange");
const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" });
const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" });
const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh];
const original = JSON.stringify(messages);
const out = toLlmMessages(messages);
assert.equal(out.length, messages.length);
assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff");
const flat = JSON.stringify(out);
assert.ok(flat.includes("OLD SUMMARY"));
assert.ok(flat.includes("LATEST SUMMARY"));
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2);
assert.equal(JSON.stringify(messages), original);
});
it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => {
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
const m1 = newUserMessage("x");
@@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize");
});
it("preserves legacy summary text when the caller has no separately supplied prior summary", () => {
const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" });
assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY"));
});
it("the prompt demands the four sections in order and carries the plan tree verbatim", () => {
const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" });
const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map(
@@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
const record = (): LhMessage[] => [
{ id: "old", role: "user", content: "dropped history", timestamp: 1 },
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 },
newCompactionMarker("SUPERSEDED SUMMARY", {
firstKeptMessageId: "kept",
tokensBefore: 98,
at: new Date(0).toISOString(),
}),
newCompactionMarker("SUMMARY", {
firstKeptMessageId: "kept",
tokensBefore: 99,
@@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
: ((m.content as { text?: string }[])[0]?.text ?? "");
assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail"));
assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history"));
assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY"));
assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1);
});
it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => {
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { completedWithin } from "./completion.ts";
test("fixture completion wait returns its result and preserves its actual error", async () => {
assert.equal(await completedWithin(Promise.resolve("saved"), "fixture save"), "saved");
const failure = new Error("fixture persistence failed");
await assert.rejects(completedWithin(Promise.reject(failure), "fixture save"), (error) => error === failure);
});
test("fixture completion wait fails clearly if completion never arrives", async () => {
await assert.rejects(
completedWithin(new Promise<never>(() => {}), "verified fixture sign-in", 0),
/Timed out after 0ms waiting for verified fixture sign-in/,
);
});
+15
View File
@@ -0,0 +1,15 @@
/** Bound an actual fixture completion signal, not a count of event-loop
* turns. Always clear the watchdog, and preserve real fixture failures. */
export async function completedWithin<T>(completion: Promise<T>, description: string, timeoutMs = 10_000): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
completion,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { repairMathForDisplay, restoreMathEscapes, transformMathDelimiters } from "../src/core/math-delims.ts";
const math = String.raw;
test("document paragraphs and display framing recover without interpreting n-commands as newlines", () => {
const source = math`First.\n\nSecond: $\nabla f\neq0$.\n\n$$\n\frac{1}{2}\n$$`;
assert.equal(repairMathForDisplay(source), "First.\n\nSecond: " + math`$\nabla f\neq0$.` + "\n\n$$\n" + math`\frac{1}{2}` + "\n$$");
});
test("literal document escapes fused to unknown math commands use document context", () => {
const source = math`Jacobian:\n\n$$\nJ=\begin{pmatrix}1&2\\3&4\end{pmatrix}\n$$`;
assert.equal(repairMathForDisplay(source), "Jacobian:\n\n$$\n" + math`J=\begin{pmatrix}1&2\\3&4\end{pmatrix}` + "\n$$");
});
test("encoded environment profiles decode row separators by their factor, not to one slash", () => {
for (const [source, expected] of [
[math`$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`],
[math`$\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}$`, math`$\begin{pmatrix}1&2\\3&4\end{pmatrix}$`],
[math`$\\begin{pmatrix}a&b\\\\\\nabla f&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\\nabla f&d\end{pmatrix}$`],
[math`$\\substack{a\\\\beta}$`, math`$\substack{a\\beta}$`],
] as const) {
assert.equal(repairMathForDisplay(source), expected);
assert.equal(repairMathForDisplay(expected), expected);
}
});
test("native substack rows and literal document escapes in genuine comments remain literal", () => {
const source = math`$\substack{a\\beta}$`;
assert.equal(repairMathForDisplay(source), source);
const comment = "$x % " + math`literal \n\n` + "\ny$";
assert.equal(repairMathForDisplay(comment), comment);
});
test("inline lost prefixes recover only known full commands; display newlines remain whitespace", () => {
assert.equal(repairMathForDisplay("$\nabla f$"), math`$\nabla f$`);
assert.equal(repairMathForDisplay("$x+\ny$"), "$x+\ny$");
assert.equal(repairMathForDisplay("$x+\nu$"), math`$x+\nu$`); // approved typical-inline-authoring policy
assert.equal(repairMathForDisplay("$$\nabla f\n$$"), "$$\nabla f\n$$"); // display source whitespace
});
test("physical display newlines and genuine row breaks/comments survive", () => {
const source = "$$\n" + math`\begin{aligned}a&=b\\% row comment` + "\nc&=d\end{aligned}\n$$";
assert.equal(repairMathForDisplay(source), source);
});
test("proper nu survives every context, including fields with document escapes", () => {
for (const source of [math`$\nu$`, "$$\n" + math`\nu` + "\n$$", math`$\nabla f+\nu+\neq$`]) {
assert.equal(repairMathForDisplay(source), source);
}
assert.equal(repairMathForDisplay(math`First.\n\n$\nu$`), "First.\n\n" + math`$\nu$`);
assert.equal(repairMathForDisplay("$$\nu\n$$"), "$$\nu\n$$"); // LF + variable u in display
});
test("tabs recover known commands including to, not arbitrary whitespace", () => {
assert.equal(repairMathForDisplay("$$\nx\t+\ty\n$$"), "$$\nx\t+\ty\n$$");
assert.equal(repairMathForDisplay("$a\to b$"), math`$a\to b$`);
assert.equal(repairMathForDisplay("$$\na\to b\n$$"), "$$\n" + math`a\to b` + "\n$$");
assert.equal(repairMathForDisplay("$$\r\nx+1\r\n$$"), "$$\r\nx+1\r\n$$");
});
test("Markdown containers protect literal code and do not swallow following content", () => {
for (const source of [
"> ~~~text\n> " + math`$\\alpha$ \n\n` + "\n> ~~~",
"> > ~~~text\n> > " + math`$\\alpha$` + "\n> > ~~~",
"- ~~~text\n " + math`$\\alpha$` + "\n ~~~",
"> ~~~text\n> " + math`$\\alpha$` + "\n\n~~~text\n" + math`$\\beta$` + "\n~~~",
]) {
assert.equal(repairMathForDisplay(source), source);
assert.equal(transformMathDelimiters(source), source);
assert.equal(repairMathForDisplay(source + "\n\n" + math`$\\gamma$`), source + "\n\n" + math`$\gamma$`);
}
});
test("numeric percentages recover, including percentages inside text groups", () => {
for (const [source, expected] of [
[math`$60\\%$`, math`$60\%$`],
[math`$50\\%+10\\%=60\\%$`, math`$50\%+10\%=60\%$`],
[math`$\text{ (50\\%)}$`, math`$\text{ (50\%)}$`],
] as const) assert.equal(repairMathForDisplay(source), expected);
});
test("numeric row breaks followed by genuine comments are not percentage escapes", () => {
const source = "$60\\\\% explanation of this row\nx$";
assert.equal(repairMathForDisplay(source), source);
const ambiguous = math`$x\\%$`;
assert.equal(repairMathForDisplay(ambiguous), ambiguous);
});
test("inline and fenced code retain all bytes, including legacy delimiters and actual control characters", () => {
const code = "```text\n\\(x\nabla\\)\n\\n\\n\n```\n" + "`" + math`\n\n $60\\%$ \(x\)` + "`";
assert.equal(repairMathForDisplay(code), code);
assert.equal(restoreMathEscapes(code), code);
});
test("math matching cannot consume a blank paragraph or a code span", () => {
const source = "Price $5.\n\nThen $x$ and `code` remains.";
assert.equal(repairMathForDisplay(source), source);
assert.equal(repairMathForDisplay("$not `code` math$"), "$not `code` math$");
});
test("Windows paths and correctly written n-commands are not document escapes", () => {
const source = math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.\n\nNext paragraph.`;
assert.equal(repairMathForDisplay(source), math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.` + "\n\nNext paragraph.");
});
test("declared n-prefixed macros are commands, not document escapes", () => {
const source = math`$\newcommand{\nThing}{x}\nThing$.\n\nNext.`;
assert.equal(repairMathForDisplay(source), math`$\newcommand{\nThing}{x}\nThing$.` + "\n\nNext.");
});
test("partial recovery cannot turn otherwise parseable TeX into a missing-argument error", () => {
const source = math`$x\\frac{a}$`;
assert.equal(repairMathForDisplay(source), source);
});
test("legacy compatibility and recovery share code barriers, including tilde and indentation", () => {
for (const source of [" " + math`\(x\) \n\n`, "\\[\n~~~tex\n" + math`\n\n \(x\)` + "\n\\]\n~~~"]) {
assert.equal(repairMathForDisplay(source), source);
assert.equal(transformMathDelimiters(source), source);
}
const comment = "$60% " + math`\n\n` + "\nx$";
assert.equal(repairMathForDisplay(comment), comment);
});
test("recovery converges after one pass, including multiple debris characters", () => {
for (const source of ["$\forall x,\theta+\to$", math`First.\n\n$$\nJ=\\frac{1}{2}+60\\%\n$$`,
math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`, "```text\n\\(\nabla\\)\n```", "$\nabla f$",
]) {
const once = repairMathForDisplay(source);
assert.equal(repairMathForDisplay(once), once);
}
});
+392
View File
@@ -0,0 +1,392 @@
/**
* Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave).
* Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer,
* resolution), the deep server module (provider build + live sync + model
* discovery incl. image-modality, reasoning detection and the per-model user
* pins), the pins' strict write-side parser, and the catalog federation row.
* No network: the discovery probe runs against a stubbed fetch.
*/
import assert from "node:assert/strict";
import { after, describe, it } from "node:test";
import {
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
CUSTOM_MODEL_PIN_FLOOR,
customProviderUsable,
normalizeBaseUrl,
parseModelIds,
sanitizeCustomModelOverrides,
sanitizeUserSettings,
} from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { parseCustomModelOverridesBody } from "../src/server/settings-body.ts";
import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts";
import {
buildOpenAiCompatibleProvider,
discoverModels,
mergeManualModels,
} from "../src/server/custom-provider.ts";
import { piModels } from "../src/agent/models.ts";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
// ─── normalizeBaseUrl ────────────────────────────────────────────────────────
describe("normalizeBaseUrl", () => {
it("drops trailing slashes and keeps the path", () => {
assert.equal(normalizeBaseUrl("https://host:8000/v1/"), "https://host:8000/v1");
assert.equal(normalizeBaseUrl(" https://host/v1 "), "https://host/v1");
assert.equal(normalizeBaseUrl("http://localhost:11434"), "http://localhost:11434");
});
it("rejects non-http(s) schemes and junk with empty string", () => {
assert.equal(normalizeBaseUrl("file:///etc/passwd"), "");
assert.equal(normalizeBaseUrl("not a url"), "");
assert.equal(normalizeBaseUrl(""), "");
});
});
// ─── customProviderUsable ────────────────────────────────────────────────────
describe("customProviderUsable", () => {
it("is true exactly when a base URL is set (the key is optional)", () => {
assert.equal(customProviderUsable({ baseUrl: "https://h/v1" }), true);
assert.equal(customProviderUsable({ baseUrl: "" }), false);
assert.equal(customProviderUsable({}), false);
assert.equal(customProviderUsable(undefined), false);
});
});
// ─── sanitizer ───────────────────────────────────────────────────────────────
describe("sanitizeUserSettings — customProvider", () => {
it("normalizes the base URL and cleans the model list (trim, dedupe, image flag)", () => {
const out = sanitizeUserSettings({
customProvider: {
baseUrl: "https://host:8000/v1/",
models: [
{ id: " llama-3 " }, // trims id; image absent
{ id: "gpt", image: true },
{ id: "gpt", image: false }, // dup id wins (last) → image dropped
{ id: "" }, // dropped: no usable id
"not-an-object", // dropped
],
},
});
const cp = out?.customProvider;
assert.equal(cp?.baseUrl, "https://host:8000/v1");
assert.deepEqual(cp?.models, [{ id: "llama-3" }, { id: "gpt" }]);
});
it("keeps image:true and drops a non-array models list / invalid base URL", () => {
const out = sanitizeUserSettings({ customProvider: { baseUrl: "ftp://x", models: "nope" } });
assert.equal(out?.customProvider?.baseUrl, undefined);
assert.equal(out?.customProvider?.models, undefined);
});
it("keeps reasoning:true and drops a non-true reasoning flag", () => {
const out = sanitizeUserSettings({
customProvider: {
baseUrl: "https://h/v1",
models: [{ id: "thinker", reasoning: true }, { id: "plain", reasoning: "yes" }],
},
});
assert.deepEqual(out?.customProvider?.models, [{ id: "thinker", reasoning: true }, { id: "plain" }]);
});
});
// ─── per-model pins: the read side + THE prune law ───────────────────────────
describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => {
it("keeps all positive integer pins and reasoning:false", () => {
const out = sanitizeUserSettings({
customProvider: {
baseUrl: "https://h/v1",
overrides: {
llama: { context: 131072, maxTokens: 8192, reasoning: true },
qwen: { context: 10 }, // Small positive budgets are preserved.
quiet: { reasoning: false }, // a real pin, not an absent one
junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk
" ": { context: 4096 }, // no usable id
nope: "not-an-object",
},
},
});
assert.deepEqual(out?.customProvider?.overrides, {
llama: { context: 131072, maxTokens: 8192, reasoning: true },
qwen: { context: 10 },
quiet: { reasoning: false },
});
});
it("prunes pins for models the endpoint no longer advertises — the single prune law", () => {
const pins = { a: { context: 32768 }, b: { context: 32768 } };
assert.deepEqual(Object.keys(sanitizeCustomModelOverrides(pins, ["a"])), ["a"]);
assert.deepEqual(
Object.keys(sanitizeCustomModelOverrides(pins)),
["a", "b"],
"no liveIds → no prune (that is the routes' call, not the record sanitizer's)",
);
assert.deepEqual(sanitizeCustomModelOverrides("nope"), {}, "a non-object map resolves empty");
});
it("drops an invalid overrides section from the record entirely", () => {
const out = sanitizeUserSettings({ customProvider: { baseUrl: "https://h/v1", overrides: "nope" } });
assert.equal(out?.customProvider?.overrides, undefined);
});
});
// ─── pins: the strict write side (PATCH) ──────────────────────────────────
describe("parseCustomModelOverridesBody — the strict write side", () => {
it("accepts whole counts at or above the floor and the tri-state reasoning pin", () => {
const r = parseCustomModelOverridesBody({
overrides: {
a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false },
b: { maxTokens: 8192 },
c: {}, // an empty pin is legal — the sanitizer drops it on write
},
});
assert.ok(r.ok);
assert.deepEqual(r.settings, {
a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false },
b: { maxTokens: 8192 },
c: {},
});
});
it("rejects a count under the floor, naming the floor", () => {
const r = parseCustomModelOverridesBody({ overrides: { a: { context: CUSTOM_MODEL_PIN_FLOOR - 1 } } });
assert.equal(r.ok, false);
if (!r.ok) assert.ok(r.error.includes(String(CUSTOM_MODEL_PIN_FLOOR)), r.error);
});
it("rejects a fractional count, a non-boolean reasoning, and a missing overrides map", () => {
assert.equal(parseCustomModelOverridesBody({ overrides: { a: { context: 4096.5 } } }).ok, false);
assert.equal(parseCustomModelOverridesBody({ overrides: { a: { reasoning: "on" } } }).ok, false);
assert.equal(parseCustomModelOverridesBody({ overrides: { a: "nope" } }).ok, false);
assert.equal(parseCustomModelOverridesBody({}).ok, false);
assert.equal(parseCustomModelOverridesBody(null).ok, false);
});
});
// ─── resolution ──────────────────────────────────────────────────────────
describe("resolveSettings — customProvider", () => {
it("resolves an absent section to concrete defaults", () => {
assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {}, manualModels: [] });
});
it("carries stored pins through to the concrete view", () => {
const resolved = resolveSettings({
version: 1,
customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], overrides: { a: { context: 32768 } } },
});
assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } });
});
it("carries stored manual model ids through to the concrete view", () => {
const resolved = resolveSettings({
version: 1,
customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], manualModels: ["a"] },
});
assert.deepEqual(resolved.customProvider.manualModels, ["a"]);
});
});
describe("manual model ids", () => {
it("parseModelIds splits a typed list on commas and whitespace, trims and dedupes", () => {
assert.deepEqual(parseModelIds(" doubao-seed-2.1-pro, kimi-k3.1-pro\nqwen/qwen3-max ,, kimi-k3.1-pro "), [
"doubao-seed-2.1-pro",
"kimi-k3.1-pro",
"qwen/qwen3-max",
]);
});
it("parseModelIds accepts a string array and ignores non-strings and junk", () => {
assert.deepEqual(parseModelIds(["a", " b ", "", 3, null, "a"]), ["a", "b"]);
assert.deepEqual(parseModelIds(undefined), []);
assert.deepEqual(parseModelIds({ id: "a" }), []);
});
it("the sanitizer keeps a clean manual list and drops an empty one", () => {
const kept = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [" a ", "a", "b"] } });
assert.deepEqual(kept?.customProvider?.manualModels, ["a", "b"]);
const dropped = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [] } });
assert.equal(dropped?.customProvider?.manualModels, undefined);
});
it("mergeManualModels adds typed ids with defaults and never replaces discovered metadata", () => {
const merged = mergeManualModels([{ id: "b", image: true, context: 65536 }], ["c", "b", "a"]);
assert.deepEqual(merged, [{ id: "a" }, { id: "b", image: true, context: 65536 }, { id: "c" }]);
});
it("mergeManualModels alone builds the list when discovery found nothing", () => {
assert.deepEqual(mergeManualModels([], ["x"]), [{ id: "x" }]);
});
});
// ─── provider build + live sync ──────────────────────────────────────────────
describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
after(() => piModels().deleteProvider(CUSTOM_PROVIDER.id));
it("maps ids to defaults and marks image-capable models image-input", () => {
const provider = buildOpenAiCompatibleProvider({
baseUrl: "http://h/v1",
models: [{ id: "m1" }, { id: "m2", image: true }],
});
assert.equal(provider.id, CUSTOM_PROVIDER.id);
const [m1, m2] = provider.getModels();
assert.equal(m1!.name, "m1");
assert.equal(m1!.api, "openai-completions");
assert.equal(m1!.provider, CUSTOM_PROVIDER.id);
assert.equal(m1!.baseUrl, "http://h/v1");
assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW);
assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS);
assert.deepEqual(m1!.input, ["text"]); // text-only by default
assert.deepEqual(m2!.input, ["text", "image"]); // image-capable → Vision-eligible
assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); // no endpoint value → default
assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS);
});
it("carries endpoint token limits when present", () => {
const provider = buildOpenAiCompatibleProvider({
baseUrl: "http://h/v1",
models: [{ id: "big", context: 196608, maxTokens: 393216 }],
});
const [big] = provider.getModels();
assert.equal(big!.contextWindow, 196608); // 192k from the endpoint
assert.equal(big!.maxTokens, 393216);
});
it("a user pin beats the endpoint, which beats the compiled default", () => {
const provider = buildOpenAiCompatibleProvider({
baseUrl: "http://h/v1",
models: [{ id: "pinned", context: 32768, maxTokens: 4096 }, { id: "detected", context: 65536 }, { id: "bare" }],
overrides: { pinned: { context: 131072, maxTokens: 16384 } },
});
const [pinned, detected, bare] = provider.getModels();
assert.equal(pinned!.contextWindow, 131072, "the pin wins over the endpoint's own number");
assert.equal(pinned!.maxTokens, 16384);
assert.equal(detected!.contextWindow, 65536, "no pin → the endpoint's value");
assert.equal(detected!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, "endpoint silent → the compiled default");
assert.equal(bare!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW);
});
it("the thinking pin is tri-state: false beats a detected-true endpoint, true beats silence", () => {
const provider = buildOpenAiCompatibleProvider({
baseUrl: "http://h/v1",
models: [{ id: "liar", reasoning: true }, { id: "silent" }],
overrides: { liar: { reasoning: false }, silent: { reasoning: true } },
});
const [liar, silent] = provider.getModels();
assert.equal(liar!.reasoning, false, "a server that advertises reasoning but rejects it can be pinned off");
assert.deepEqual(getSupportedThinkingLevels(liar!), ["off"]);
assert.equal(silent!.reasoning, true, "LM Studio publishes nothing; the user says otherwise");
assert.deepEqual(getSupportedThinkingLevels(silent!), ["off", "minimal", "low", "medium", "high"]);
});
it("gives a reasoning-capable model the standard thinking levels (others stay \"off\")", () => {
const provider = buildOpenAiCompatibleProvider({
baseUrl: "http://h/v1",
models: [{ id: "thinker", reasoning: true }, { id: "plain" }],
});
const [thinker, plain] = provider.getModels();
assert.equal(thinker!.reasoning, true);
// No thinkingLevelMap → pi-ai's default set, same as any reasoning model.
assert.deepEqual(getSupportedThinkingLevels(thinker!), ["off", "minimal", "low", "medium", "high"]);
assert.equal(plain!.reasoning, false);
assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]);
});
it("registers the native provider in the shared collection", () => {
piModels().setProvider(buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }));
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable");
piModels().deleteProvider(CUSTOM_PROVIDER.id);
assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed");
});
});
// ─── discovery (stubbed fetch) ───────────────────────────────────────────────
describe("discoverModels", () => {
const realFetch = globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
function stubFetch(handler: (url: string) => { status: number; body: unknown }) {
globalThis.fetch = (async (input: unknown) => {
const res = handler(String(input));
return new Response(JSON.stringify(res.body), { status: res.status });
}) as unknown as typeof fetch;
}
it("reads OpenAI {data:[{id}]} and Ollama {models:[{id}]} shapes", async () => {
stubFetch(() => ({ status: 200, body: { data: [{ id: "b" }, { id: "a" }] } }));
const openai = await discoverModels("http://h/v1");
assert.ok(openai.ok);
assert.deepEqual(openai.models, [{ id: "a" }, { id: "b" }]);
stubFetch(() => ({ status: 200, body: { models: [{ id: "x" }] } }));
const r = await discoverModels("http://h/v1");
assert.ok(r.ok && r.models.length === 1 && r.models[0]!.id === "x");
});
it("detects image capability from common modality fields", async () => {
stubFetch(() => ({
status: 200,
body: {
data: [
{ id: "vision-model", architecture: { input_modalities: ["text", "image"] } },
{ id: "cap-model", capabilities: ["completion", "vision"] },
{ id: "text-model", architecture: { input_modalities: ["text"] } },
],
},
}));
const r = await discoverModels("http://h/v1");
assert.ok(r.ok);
const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.image)]));
assert.equal(byId["vision-model"], true);
assert.equal(byId["cap-model"], true);
assert.equal(byId["text-model"], false);
});
it("detects token limits from common metadata fields", async () => {
stubFetch(() => ({
status: 200,
body: {
data: [
{ id: "big", max_input_tokens: 196608, max_output_tokens: 393216 },
{ id: "ctx", context_length: 32768 },
{ id: "plain" },
],
},
}));
const r = await discoverModels("http://h/v1");
assert.ok(r.ok);
const byId = Object.fromEntries(r.models.map((m) => [m.id, m]));
assert.equal(byId["big"]!.context, 196608);
assert.equal(byId["big"]!.maxTokens, 393216);
assert.equal(byId["ctx"]!.context, 32768);
assert.equal(byId["ctx"]!.maxTokens, undefined);
assert.equal(byId["plain"]!.context, undefined);
});
it("detects reasoning from endpoint metadata only — never from the model name", async () => {
stubFetch(() => ({
status: 200,
body: {
data: [
{ id: "vllm-reasoner", supported_parameters: ["temperature", "reasoning", "reasoning_effort"] },
{ id: "bool-reasoner", reasoning: true },
{ id: "thinking-flag", thinking: true },
{ id: "cap-reasoner", capabilities: ["completion", "reasoning"] },
{ id: "deepseek-r1-distill" }, // a reasoning-sounding NAME, no metadata → stays off
],
},
}));
const r = await discoverModels("http://h/v1");
assert.ok(r.ok);
const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.reasoning)]));
assert.equal(byId["vllm-reasoner"], true);
assert.equal(byId["bool-reasoner"], true);
assert.equal(byId["thinking-flag"], true);
assert.equal(byId["cap-reasoner"], true);
assert.equal(byId["deepseek-r1-distill"], false, "name guessing would risk sending reasoning_effort to a server that rejects it");
});
it("guards the scheme before any fetch", async () => {
let called = false;
globalThis.fetch = (async () => {
called = true;
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
const r = await discoverModels("file:///etc/passwd");
assert.equal(r.ok, false);
assert.equal(called, false, "no fetch for a non-http scheme");
});
it("reports a non-2xx and an unparseable shape as honest errors", async () => {
stubFetch(() => ({ status: 500, body: {} }));
assert.equal((await discoverModels("http://h/v1")).ok, false);
stubFetch(() => ({ status: 200, body: { unexpected: true } }));
assert.equal((await discoverModels("http://h/v1")).ok, false);
});
});
+217
View File
@@ -0,0 +1,217 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test, mock } from "node:test";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-boundaries-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
const route = await import("../app/api/custom-provider/route.ts");
const keys = await import("../src/server/keys.ts");
const { authEnvRecord } = await import("../src/core/auth-env.ts");
const { CUSTOM_PROVIDER } = await import("../src/core/auth-keys.ts");
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
const { readCustomAuth } = await import("../src/store/auth.ts");
const { persistCustomAuth } = await import("../src/server/custom-provider-auth.ts");
const settingsFile = path.join(scratch, "settings.json");
const seed = (cp: import("../src/core/settings.ts").CustomProviderSettings = { baseUrl: "http://old/v1", authMode: "keyless", models: [{ id: "m" }] }, key?: string) => fs.writeFileSync(settingsFile, JSON.stringify({ version: 1, customProvider: authorizeFixture(cp, key) }));
const setFileKey = (key: string) => { const auth = readCustomAuth(); persistCustomAuth({ ...auth.binding!, fileKeyBaseUrl: auth.binding!.baseUrl }, key); };
const current = () => JSON.parse(fs.readFileSync(settingsFile, "utf8"));
const get = (inspect = false) => route.GET(new Request(`http://localhost/api/custom-provider${inspect ? "?authMode=apiKey" : ""}`));
function mutate(body: unknown, method = "POST", headers: Record<string, string> = {}) {
return route.POST(new Request("http://localhost/api/custom-provider", { method, headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get().headers.get("etag")!, ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }));
}
let requests: { url: string; authorization: string | null }[];
beforeEach(() => {
mock.restoreAll();
process.env.LH_IGNORE_ENV_KEYS = "1";
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); seed();
requests = [];
globalThis.fetch = async (url, options) => {
requests.push({ url: String(url), authorization: new Headers(options?.headers).get("authorization") });
assert.equal(options?.redirect, "manual");
assert.ok(options?.signal instanceof AbortSignal);
return Response.json({ data: [{ id: "m" }] });
};
});
after(() => { mock.restoreAll(); process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
test("keyless probe never forwards a stored key, and clearing removes authentication", async () => {
setFileKey("private-old");
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "keyless", key: "do-not-use-this" })).status, 200);
assert.equal(requests[0]?.authorization, null);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), undefined);
assert.equal(current().customProvider.authMode, "keyless");
});
test("new keys have no arbitrary minimum; the exact probed key is stored", async () => {
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: " k " })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer k");
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k");
const body = await get().text(); assert.ok(!body.includes("Bearer"));
});
test("actual GET /api/keys fully redacts every custom key length and preserves other providers", async () => {
const inventory = await import("../app/api/keys/route.ts");
const rows = async () => (await (await inventory.GET()).json()).keys as import("../src/core/auth-keys.ts").AuthKeyStatus[];
assert.equal(keys.saveKey("OPENAI_API_KEY", "ordinary-provider-key-abcd").ok, true);
const ordinary = (await rows()).find(row => row.id === "OPENAI_API_KEY")!;
assert.equal(ordinary.masked, "…abcd (26 chars)");
try {
for (const value of ["1", "12", "123", "1234", "synthetic-custom-secret-longer-than-four"]) {
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: value })).status, 200);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), value, "short keys still save unchanged");
const statuses = await rows();
const custom = statuses.find(row => row.id === CUSTOM_PROVIDER.envVar)!;
assert.equal(custom.masked, "••••"); assert.equal(custom.present, true); assert.equal(custom.source, "file");
assert.ok(!JSON.stringify(custom).includes(value));
assert.deepEqual(statuses.find(row => row.id === "OPENAI_API_KEY"), ordinary);
}
process.env.LH_IGNORE_ENV_KEYS = "0";
process.env[CUSTOM_PROVIDER.envVar] = "765"; authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
const custom = (await rows()).find(row => row.id === CUSTOM_PROVIDER.envVar)!;
assert.equal(custom.masked, "••••"); assert.equal(custom.source, "env"); assert.equal(custom.present, true); assert.equal(custom.shadowedByEnv, true);
assert.ok(!JSON.stringify(custom).includes("765"));
} finally {
authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; process.env.LH_IGNORE_ENV_KEYS = "1";
keys.deleteKey("OPENAI_API_KEY");
}
});
test("changing destination cannot silently reuse an existing credential", async () => {
setFileKey("private-old");
const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };
assert.equal((await mutate(payload)).status, 400); assert.equal(requests.length, 0);
assert.equal((await mutate({ ...payload, reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer private-old");
});
test("same-URL unbound development key needs existing explicit reuse consent", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "legacy-key");
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
const file = path.join(scratch, "auth.json"); const map = JSON.parse(fs.readFileSync(file, "utf8")); delete map[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(map));
assert.equal((await get().json()).keyReuseRequired, true);
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 400); assert.equal(requests.length, 0);
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey", reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer legacy-key"); assert.equal((await get().json()).keyReuseRequired, false);
});
test("generic custom/reserved key writes cannot bypass the atomic binding seam", async () => {
const ordinary = await import("../app/api/keys/[id]/route.ts");
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
const { authStore } = await import("../src/store/auth.ts");
const before = fs.readFileSync(path.join(scratch, "auth.json"), "utf8");
for (const id of [CUSTOM_PROVIDER.envVar, CUSTOM_AUTH_BINDING]) {
const context = { params: Promise.resolve({ id }) };
assert.equal((await ordinary.PUT(new Request("http://localhost", { method: "PUT", body: JSON.stringify({ value: "bypass" }) }), context)).status, 404);
assert.equal((await ordinary.DELETE(new Request("http://localhost", { method: "DELETE" }), context)).status, 404);
assert.equal(keys.saveKey(id, "bypass").ok, false); assert.throws(() => keys.deleteKey(id));
assert.throws(() => authStore.setAuthVar(id, "bypass")); assert.throws(() => authStore.deleteAuthVar(id));
}
assert.equal(fs.readFileSync(path.join(scratch, "auth.json"), "utf8"), before);
assert.ok(!keys.keyStatuses().some(row => row.id === CUSTOM_AUTH_BINDING)); assert.equal(process.env[CUSTOM_AUTH_BINDING], undefined);
});
test("same target can retain its effective key", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "retained");
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer retained");
});
test("environment ownership is shown; consent uses the environment key in probe and inference", async () => {
process.env.LH_IGNORE_ENV_KEYS = "0";
process.env[CUSTOM_PROVIDER.envVar] = "environment-secret";
authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
try {
assert.equal((await get(true).json()).keySource, "env");
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: "shadowed" })).status, 400);
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer environment-secret");
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "environment-secret");
assert.ok(!(await get().text()).includes("environment-secret"));
assert.equal((await mutate({ baseUrl: "http://other/v1", authMode: "keyless" })).status, 200);
assert.equal(requests.at(-1)?.authorization, null);
} finally { authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; }
});
test("cross-origin/opaque/missing-origin and non-JSON writes cannot probe or mutate", async () => {
for (const origin of ["http://evil", "null", ""]) assert.equal((await mutate({ baseUrl: "http://new", authMode: "keyless" }, "POST", { origin })).status, 403);
assert.equal((await mutate({}, "POST", { "content-type": "text/plain" })).status, 415);
assert.equal((await mutate({}, "POST", { "sec-fetch-site": "cross-site" })).status, 403);
assert.equal(requests.length, 0);
});
test("standalone internal URL uses the addressed Host origin, never forwarded origins", async () => {
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, host: "127.0.0.1:3317", origin: "http://127.0.0.1:3317" };
assert.equal((await route.DELETE(new Request("http://localhost:3317/api/custom-provider", { method: "DELETE", headers }))).status, 200);
assert.equal((await mutate({}, "DELETE", { "x-forwarded-host": "evil", origin: "http://evil" })).status, 403);
});
test("empty DELETE streams normalize consistently without accepting malformed JSON", async () => {
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, origin: "http://localhost" };
const init: RequestInit & { duplex: "half" } = { method: "DELETE", headers, body: new ReadableStream({ start(controller) { controller.close(); } }), duplex: "half" };
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", init))).status, 200);
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", { method: "DELETE", headers: { ...headers, "if-match": get().headers.get("etag")! }, body: "{" }))).status, 400);
assert.equal((await route.POST(new Request("http://localhost/api/custom-provider", { method: "POST", headers, body: "" }))).status, 400);
});
test("desktop capability remains mandatory in desktop mode", async () => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "x".repeat(64);
assert.equal((await mutate({}, "DELETE")).status, 403);
assert.equal((await mutate(undefined, "DELETE", { origin: "null", "x-learnharness-desktop": "x".repeat(64) })).status, 200);
});
test("forbidden URL components are rejected before probing", async () => {
for (const baseUrl of ["ftp://h", "https://user:pass@h/v1", "http://h/v1?q=secret", "http://h/v1#x", "http://h/v1?", "http://h/v1#"]) assert.equal((await mutate({ baseUrl, authMode: "keyless" })).status, 400);
assert.equal(requests.length, 0);
});
test("redirects/auth failures/model and byte limits cannot be bypassed with typed IDs", async () => {
for (const answer of [
() => new Response(null, { status: 302, headers: { location: "http://other" } }),
() => new Response("private response", { status: 401 }),
() => new Response("private response", { status: 403 }),
() => Response.json({ data: Array(10001).fill({ id: "m" }) }),
() => new Response("x".repeat(8 * 1024 * 1024 + 1)),
() => new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } }),
]) {
globalThis.fetch = async () => answer();
const before = current();
const res = await mutate({ baseUrl: "http://h/v1", authMode: "keyless", manualModels: "m" });
assert.equal(res.status, 502); assert.deepEqual(current(), before);
assert.ok(!(await res.text()).includes("private response"));
}
});
test("write size bounds are explicit for streamed bodies", async () => {
assert.equal((await mutate({ baseUrl: "x".repeat(65536), authMode: "keyless" })).status, 413);
assert.equal((await mutate({ padding: "x".repeat(65536) }, "DELETE")).status, 413);
assert.equal(requests.length, 0);
});
test("stale save, pin edit and disconnect all return reload guidance without writing", async () => {
const etag = get().headers.get("etag")!;
seed({ baseUrl: "http://other/v1", authMode: "keyless", models: [{ id: "different" }] });
const before = current();
for (const method of ["POST", "PATCH", "DELETE"]) {
const res = await mutate({ baseUrl: "http://h", authMode: "keyless", overrides: {} }, method, { "if-match": etag });
assert.equal(res.status, 409); assert.match(await res.text(), /Reload/);
}
assert.equal(requests.length, 0); assert.deepEqual(current(), before);
});
test("after discovery preserves unrelated edits and rejects intervening custom edits", async () => {
globalThis.fetch = async () => { fs.writeFileSync(settingsFile, JSON.stringify({ ...current(), appearance: { mode: "dark" }, claudeCode: { enabled: true } })); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 200);
assert.deepEqual(current().appearance, { mode: "dark" }); assert.equal(current().claudeCode.enabled, true);
globalThis.fetch = async () => { seed({ baseUrl: "http://changed", authMode: "keyless", models: [] }); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 409);
assert.equal(current().customProvider.baseUrl, "http://changed");
});
test("after discovery rejects a changed effective key", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "before");
globalThis.fetch = async () => { setFileKey("rotated"); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 409);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "rotated");
});
test("offline pins accept 512/1, retain support declaration, and do not probe", async () => {
const res = await mutate({ overrides: { m: { context: 1, maxTokens: 512, reasoning: false } } }, "PATCH");
assert.equal(res.status, 200); assert.equal(requests.length, 0);
assert.deepEqual(current().customProvider.overrides.m, { context: 1, maxTokens: 512, reasoning: false });
});
test("settings-write failure restores previous key/config and reports failure", async () => {
setFileKey("old-key");
const before = current();
const write = fs.writeFileSync;
mock.method(fs, "writeFileSync", (...args: Parameters<typeof write>) => { if (args[0] === settingsFile) throw new Error("private-io-details"); return write(...args); });
const res = await mutate({ baseUrl: "http://new", authMode: "apiKey", key: "new-key" });
assert.equal(res.status, 500); assert.ok(!(await res.text()).includes("private-io-details"));
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "old-key"); assert.deepEqual(current(), before);
});
+129
View File
@@ -0,0 +1,129 @@
/** Native Pi payloads with synthetic transport and owned scratch files only. */
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { normalizeContext, fauxAssistantMessage, fauxToolCall, type Message } from "@earendil-works/pi-ai";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-lifecycle-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
const { writeSettings, readSettings } = await import("../src/store/settings.ts");
const { piEngineForSpec, piModels } = await import("../src/agent/models.ts");
await import("../src/server/keys.ts");
const { readCustomAuth } = await import("../src/store/auth.ts");
const { persistCustomAuth, customAuthState } = await import("../src/server/custom-provider-auth.ts");
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
const { modelCatalog, catalogContextLimits } = await import("../src/server/model-catalog.ts");
const { callCost } = await import("../src/server/pricing.ts");
const { createReadTool } = await import("../src/agent/tools/read.ts");
const { installCustomProviderSource } = await import("../src/server/custom-provider.ts");
let requests: { url: string; auth: string | null; body: Record<string, unknown> }[];
function configure(baseUrl = "http://first/v1", reasoning = false, maxTokens = 512, authMode: "keyless" | "apiKey" = "apiKey") {
const key = authMode === "apiKey" ? readCustomAuth().fileKey() ?? "first-key" : undefined;
writeSettings({ version: 1, customProvider: authorizeFixture({ baseUrl, authMode, models: [{ id: "m", context: 16384, maxTokens, reasoning }] }, key) });
}
function answer() {
return new Response(`data: ${JSON.stringify({ id: "fake", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } });
}
function capture(url: unknown, init?: RequestInit) {
requests.push({ url: String(url), auth: new Headers(init?.headers).get("authorization"), body: JSON.parse(String(init?.body)) });
}
const context = (messages: Message[] = [{ role: "user", content: "synthetic", timestamp: 0 }]) => normalizeContext({ messages });
const run = (engine: ReturnType<typeof piEngineForSpec>, messages?: Message[]) => engine.streamFn(engine.model, context(messages), {}).result();
beforeEach(() => {
requests = []; fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); configure();
globalThis.fetch = async (url, init) => { capture(url, init); return answer(); };
});
after(() => { process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
test("native requests revalidate endpoint/model/key/limits with no fallback and retain in-flight setup", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
let started!: () => void; const ready = new Promise<void>((r) => { started = r; });
let release!: () => void;
globalThis.fetch = async (url, init) => { capture(url, init); started(); return new Promise<Response>((resolve) => { release = () => resolve(answer()); }); };
const inFlight = run(engine); await ready;
configure("http://second/v1", false, 1024); persistCustomAuth(readCustomAuth().binding, "second-key");
globalThis.fetch = async (url, init) => { capture(url, init); return answer(); };
assert.equal((await run(engine)).stopReason, "stop");
release(); assert.equal((await inFlight).stopReason, "stop");
assert.match(requests[0]!.url, /^http:\/\/first\/v1\/chat\/completions$/);
assert.equal(requests[0]!.auth, "Bearer first-key"); assert.equal(requests[0]!.body.max_completion_tokens, 512);
assert.match(requests[1]!.url, /^http:\/\/second\/v1\/chat\/completions$/);
assert.equal(requests[1]!.auth, "Bearer second-key"); assert.equal(requests[1]!.body.max_completion_tokens, 1024);
writeSettings({ version: 1, customProvider: { ...readSettings()!.customProvider, models: [{ id: "other" }] } });
assert.ok(piModels().getProvider("custom"), "provider stays authorized while this model is removed");
assert.equal((await run(engine)).stopReason, "error");
writeSettings({ version: 1 }); assert.equal((await run(engine)).stopReason, "error");
assert.equal(requests.length, 2);
assert.ok(piModels().getProvider("openai"), "native route survives custom removal");
});
test("current key changes without configuration edits are reflected on the next operation", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
await run(engine); persistCustomAuth(readCustomAuth().binding, "rotated-key"); await run(engine);
assert.deepEqual(requests.map((r) => r.auth), ["Bearer first-key", "Bearer rotated-key"]);
});
test("original saved thinking preference recovers after capability expansion and contracts again", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m", thinkingLevel: "high" });
assert.equal(engine.level, "off");
await run(engine); configure("http://first/v1", true); await run(engine); configure(); await run(engine);
assert.deepEqual(requests.map((r) => r.body.reasoning_effort), [undefined, "high", undefined]);
});
test("unset preference reselects the existing default against current reasoning metadata", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
configure("http://first/v1", true); await run(engine);
assert.equal(requests[0]!.body.reasoning_effort, "medium");
configure(); await run(engine); assert.equal(requests[1]!.body.reasoning_effort, undefined);
});
test("catalog capabilities remain reported and prices unknown despite runtime image permission", () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
assert.deepEqual(engine.model.input, ["text", "image"]);
const dto = modelCatalog().models.find((m) => m.provider === "custom")!;
assert.deepEqual(dto.input, ["text"]); assert.equal(dto.cost, null);
assert.equal(callCost({ inputTokens: 100, outputTokens: 100 }, "custom:m"), null);
assert.equal(catalogContextLimits().get("custom:m"), 16384);
});
const image = { type: "image" as const, data: createCanvas(1, 1).toBuffer("image/png").toString("base64"), mimeType: "image/png" };
test("actual native encoder preserves USER and tool-result images under missing metadata, including after reload", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "look" }, image], timestamp: 0 },
fauxAssistantMessage([fauxToolCall("read", {}, { id: "read-1" })], { stopReason: "toolUse" }),
{ role: "toolResult", toolCallId: "read-1", toolName: "read", content: [image], isError: false, timestamp: 1 },
];
await run(engine, messages); configure("http://second/v1"); await run(engine, messages);
for (const request of requests) {
const body = JSON.stringify(request.body.messages);
assert.equal((body.match(/"type":"image_url"/g) ?? []).length, 2);
assert.equal((body.match(/data:image\/png;base64/g) ?? []).length, 2);
assert.ok(!body.includes("image omitted"));
}
assert.deepEqual(piModels().getModel("custom", "m")!.input, ["text"]);
});
test("teacher-bound read returns image without contradictory omission note", async () => {
const file = path.join(scratch, "synthetic.png"); fs.writeFileSync(file, Buffer.from(image.data, "base64"));
const engine = piEngineForSpec({ provider: "custom", id: "m" });
const result = await createReadTool(engine.model).execute("read-1", { path: file });
assert.ok(result.content.some((block) => block.type === "image"));
assert.ok(!JSON.stringify(result).includes("image will be omitted"));
});
test("backend image rejection surfaces once, never retries without images", async () => {
globalThis.fetch = async (url, init) => { capture(url, init); return Response.json({ error: { message: "images rejected by backend" } }, { status: 400 }); };
const result = await run(piEngineForSpec({ provider: "custom", id: "m" }), [{ role: "user", content: [image], timestamp: 0 }]);
assert.equal(result.stopReason, "error"); assert.match(result.errorMessage ?? "", /images rejected/); assert.equal(requests.length, 1);
});
test("existing engine refuses nonce mismatch on EVERY operation, without falling back to env", async () => {
const engine = piEngineForSpec({ provider: "custom", id: "m" }); await run(engine);
persistCustomAuth({ ...readCustomAuth().binding!, id: "b".repeat(32) }, "different-key");
assert.equal((await run(engine)).stopReason, "error"); assert.equal(requests.length, 1);
});
test("keyless publication never reads the credential lookup", async () => {
configure("http://first/v1", false, 512, "keyless");
let credentialLookups = 0;
installCustomProviderSource((inspect, cp) => { if (inspect) credentialLookups++; return customAuthState(inspect, cp); });
try { assert.equal((await run(piEngineForSpec({ provider: "custom", id: "m" }))).stopReason, "stop"); assert.equal(credentialLookups, 0); assert.equal(requests[0]!.auth, "Bearer not-needed"); }
finally { installCustomProviderSource(); }
});
+81
View File
@@ -0,0 +1,81 @@
/** No real endpoints/accounts. Actual process death and cold startup, not HMR. */
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { after, test } from "node:test";
import { CUSTOM_AUTH_BINDING } from "../src/core/custom-provider-auth.ts";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-restart-"));
after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = "http://first.invalid/v1";
function profile() {
const home = fs.mkdtempSync(path.join(root, "case-"));
const id = "a".repeat(32);
fs.writeFileSync(path.join(home, "settings.json"), JSON.stringify({ version: 1, appearance: { mode: "dark" }, customProvider: { baseUrl: base, authMode: "apiKey", credentialBindingId: id, models: [{ id: "m" }] } }));
fs.writeFileSync(path.join(home, "auth.json"), JSON.stringify({ CUSTOM_OPENAI_API_KEY: "fixture-old-key", OTHER_PROVIDER_KEY: "untouched", future: { preserve: true }, [CUSTOM_AUTH_BINDING]: JSON.stringify({ version: 1, id, baseUrl: base, authMode: "apiKey", fileKeyBaseUrl: base }) }), { mode: 0o600 });
return home;
}
function child(home: string, mode: "mutate" | "inspect", action = "key", fault = "none", envKey?: string) {
const result = spawnSync(process.execPath, ["--import", "tsx", "tests/helpers/custom-provider-crash.ts", mode, action, fault], { cwd: process.cwd(), env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, NODE_ENV: "test", LH_DATA_DIR: home, LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "0", ...(envKey ? { CUSTOM_OPENAI_API_KEY: envKey } : {}) }, encoding: "utf8", timeout: 15000 });
assert.ifError(result.error);
assert.ok(result.status === 0 || result.status === 77, `${result.status}: ${result.stderr}`);
return { exit: result.status, data: result.stdout.trim() ? JSON.parse(result.stdout.trim().split("\n").at(-1)!) : undefined };
}
function closed(home: string, envKey?: string) {
const { data } = child(home, "inspect", "key", "none", envKey);
assert.equal(data.enabled, false); assert.deepEqual(data.requests, []); assert.equal(data.metadataInEnv, false);
assert.ok(!data.statusIds.includes(CUSTOM_AUTH_BINDING));
}
test("cold restart refuses the new auth/old URL gap and double-I/O failure, with or without env", () => {
for (const fault of ["after-auth", "double-io"]) {
const home = profile(); const result = child(home, "mutate", "key", fault);
if (fault === "after-auth") assert.equal(result.exit, 77); else assert.equal(result.data.status, 500);
closed(home); closed(home, "fixture-real-env");
const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8"));
assert.equal(auth.OTHER_PROVIDER_KEY, "untouched"); assert.deepEqual(auth.future, { preserve: true });
}
});
test("failed auth publication retains old matching setup; landed settings plus restored auth fails closed", () => {
const home = profile(); assert.equal(child(home, "mutate", "key", "auth-before-rename").data.status, 500);
const old = child(home, "inspect").data; assert.equal(old.enabled, true); assert.equal(old.requests[0].url, base + "/chat/completions"); assert.equal(old.requests[0].authorization, "Bearer fixture-old-key");
const other = profile(); assert.equal(child(other, "mutate", "key", "settings-landed").data.status, 500); closed(other);
});
test("clear/disconnect half commits stay closed across restart even with genuine env keys", () => {
for (const action of ["keyless", "disconnect"]) {
for (const fault of ["after-auth", "double-io"]) {
const home = profile(); child(home, "mutate", action, fault, "fixture-real-env"); closed(home, "fixture-real-env");
}
}
});
test("successful keyless checks only binding; disconnect tombstone blocks latent env after restart", () => {
const home = profile(); assert.equal(child(home, "mutate", "keyless", "none", "fixture-real-env").data.status, 200);
const state = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(state.enabled, true); assert.equal(state.requests[0].authorization, "Bearer not-needed");
assert.equal(child(home, "mutate", "disconnect", "none", "fixture-real-env").data.status, 200); closed(home, "fixture-real-env");
});
test("env reuse never reauthorizes a shadowed file key for another destination", () => {
const home = profile(); assert.equal(child(home, "mutate", "envMove", "none", "fixture-real-env").data.status, 200);
const live = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].url, "http://second.invalid/v1/chat/completions"); assert.equal(live.requests[0].authorization, "Bearer fixture-real-env");
closed(home);
const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8")); assert.equal(JSON.parse(auth[CUSTOM_AUTH_BINDING]).fileKeyBaseUrl, base); assert.equal(auth.CUSTOM_OPENAI_API_KEY, "fixture-old-key");
assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.requests[0].authorization, "Bearer fixture-old-key");
});
test("unbound development records require explicit manual reauthorization, never automatic migration", () => {
const home = profile(); const file = path.join(home, "auth.json"); const auth = JSON.parse(fs.readFileSync(file, "utf8")); delete auth[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(auth));
closed(home); closed(home, "fixture-real-env");
assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.enabled, true);
});
test("malformed/unreadable private snapshots are never rewritten or used, unrelated bytes survive", () => {
for (const raw of ['{"OTHER_PROVIDER_KEY":"do-not-erase",', '[]', JSON.stringify({ OTHER_PROVIDER_KEY: "do-not-erase", [CUSTOM_AUTH_BINDING]: "not-json" })]) {
const home = profile(); const file = path.join(home, "auth.json"); fs.writeFileSync(file, raw);
closed(home, "fixture-real-env"); assert.equal(child(home, "mutate").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), raw);
}
const home = profile(); const file = path.join(home, "auth.json"); const before = fs.readFileSync(file, "utf8");
assert.equal(child(home, "mutate", "key", "unreadable").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), before);
assert.equal(child(home, "inspect", "key", "unreadable", "fixture-real-env").data.enabled, false);
});
test("missing auth file initializes normally with explicit new key, preserving other settings", () => {
const home = profile(); fs.unlinkSync(path.join(home, "auth.json")); closed(home);
assert.equal(child(home, "mutate").data.status, 200); const live = child(home, "inspect").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].authorization, "Bearer fixture-new-key");
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8")).appearance, { mode: "dark" });
});
+130
View File
@@ -0,0 +1,130 @@
/**
* POST /api/custom-provider — the connect policy around manually typed model
* ids. Two rules earn a route test (the pure pieces live in
* custom-endpoint.test.ts):
* 1. Typed ids make a FAILED probe survivable, but never a rejected key.
* 2. A failed probe never shrinks the snapshot it already stored: that prune
* would take the model metadata and the user's pins with it.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, before, describe, it } from "node:test";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-route-"));
process.env.LH_DATA_DIR = dir;
process.env.LH_AUTH_FILE = path.join(dir, "auth.json");
const SETTINGS_FILE = path.join(dir, "settings.json");
const realFetch = globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
fs.rmSync(dir, { recursive: true, force: true });
delete process.env.LH_DATA_DIR;
delete process.env.LH_AUTH_FILE;
});
type Route = typeof import("../app/api/custom-provider/route.ts");
let route: Route;
before(async () => {
route = await import("../app/api/custom-provider/route.ts");
});
const BASE = "http://h/v1";
/** A connected endpoint with two discovered models, a pin on each, one typed id. */
const CONNECTED = {
version: 1,
customProvider: {
baseUrl: BASE,
authMode: "keyless",
models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }],
overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } },
manualModels: ["m1"],
},
};
function seed(record: unknown): void {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(record, null, "\t"), "utf-8");
}
function stored(): { customProvider?: { models?: { id: string }[]; overrides?: unknown; manualModels?: string[] } } {
return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
}
function answerWith(status: number, statusText: string, body?: unknown): void {
globalThis.fetch = (async () => (body === undefined ? new Response("nope", { status, statusText }) : Response.json(body))) as typeof fetch;
}
function post(payload: unknown): Promise<Response> {
return route.POST(
new Request("http://localhost/api/custom-provider", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://localhost", "if-match": route.GET(new Request("http://localhost/api/custom-provider")).headers.get("etag")! },
body: JSON.stringify({ authMode: payload && typeof payload === "object" && "key" in payload ? "apiKey" : "keyless", ...payload as object }),
}),
);
}
describe("POST /api/custom-provider — typed ids and a failed probe", () => {
it("a transient failure keeps the stored models, their metadata, and their pins", async () => {
seed(CONNECTED);
answerWith(503, "Service Unavailable");
const res = await post({ baseUrl: BASE, manualModels: "m1" });
assert.equal(res.status, 200);
const cp = stored().customProvider;
assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "disc-2", "m1"]);
assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 131072 }, "detected metadata survives");
assert.deepEqual(cp?.overrides, CONNECTED.customProvider.overrides, "the prune must not fire on a failed probe");
assert.deepEqual(cp?.manualModels, ["m1"]);
});
it("a rejected key fails the connect even when ids are typed", async () => {
seed(CONNECTED);
answerWith(401, "Unauthorized");
const before = stored();
const res = await post({ baseUrl: BASE, key: "sk-abcdefghijk", manualModels: "m1" });
assert.equal(res.status, 502);
assert.deepEqual(stored(), before, "nothing is stored on an auth failure");
});
it("a new endpoint with no model list starts from the typed ids alone", async () => {
seed(CONNECTED);
answerWith(404, "Not Found");
const res = await post({ baseUrl: "http://other/v1", manualModels: "y, x" });
assert.equal(res.status, 200);
const cp = stored().customProvider;
assert.deepEqual(cp?.models?.map((m) => m.id), ["x", "y"], "the previous endpoint's models do not follow the URL");
assert.equal(cp?.overrides, undefined, "no pin survives a list the old pins do not belong to");
assert.deepEqual(cp?.manualModels, ["y", "x"], "the typed order is kept as typed");
});
it("no model list and no typed ids still refuses, with the hint", async () => {
answerWith(404, "Not Found");
const res = await post({ baseUrl: BASE });
assert.equal(res.status, 502);
const body = (await res.json()) as { error: string };
assert.match(body.error, /404/);
assert.match(body.error, /add model IDs manually/);
});
it("a working probe merges the typed ids and keeps discovered metadata", async () => {
seed(CONNECTED);
answerWith(200, "OK", { data: [{ id: "disc-1", context_length: 65536 }] });
const res = await post({ baseUrl: BASE, manualModels: "disc-1, m2" });
assert.equal(res.status, 200);
const cp = stored().customProvider;
assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "m2"]);
assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 65536 }, "discovery is not replaced by a bare typed id");
assert.deepEqual(cp?.overrides, { "disc-1": { context: 200000 } }, "pins follow the ids this connect offers");
assert.deepEqual(cp?.manualModels, ["disc-1", "m2"]);
});
});
+51
View File
@@ -0,0 +1,51 @@
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 { KeysSection, CustomEndpointSetup } from "../components/settings/keys-section.tsx";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined;
let host: HTMLElement | undefined;
async function mount(setup = false) {
globalThis.fetch = async (input, init) => {
assert.equal(init?.method ?? "GET", "GET", "polish interactions must not save credentials/settings");
if (String(input).startsWith("/api/custom-provider")) return Response.json({ baseUrl: "http://fixture.invalid/v1", authMode: "keyless", keySource: "none", keyPresent: false, keyReuseRequired: false, models: [{ id: "fixture-model" }], overrides: {}, manualModels: [] }, { headers: { etag: '"fixture"' } });
return Response.json({ keys: [], providers: [] });
};
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
await act(async () => root!.render(setup ? <CustomEndpointSetup onSaved={() => assert.fail("unexpected save")} /> : <KeysSection />));
if (!setup) await click("custom-endpoint-row-btn");
}
function element(id: string) { const node = document.querySelector<HTMLElement>(`[data-testid="${id}"]`); assert.ok(node, id); return node; }
async function click(id: string) { await act(async () => element(id).dispatchEvent(new dom.window.Event("click", { bubbles: true }))); }
async function selectMode(value: string) {
const select = element("custom-auth-mode");
Object.defineProperty(select, "value", { value, configurable: true });
await act(async () => select.dispatchEvent(new dom.window.Event("change", { bubbles: true })));
}
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; });
for (const setup of [false, true]) {
test(`${setup ? "Setup" : "Settings"} hides key input in Keyless and reveals it only in API key mode`, async () => {
await mount(setup);
assert.equal(document.querySelector('[data-testid="custom-key-input"]'), null);
await selectMode("apiKey"); assert.equal(element("custom-key-input").getAttribute("type"), "password");
await selectMode("keyless"); assert.equal(document.querySelector('[data-testid="custom-key-input"]'), null);
assert.ok(element("custom-save"));
});
}
test("model controls use approved effort labels, right-align Save limits, and group help below", async () => {
await mount();
const select = element("custom-pin-reasoning-fixture-model");
assert.equal(select.getAttribute("aria-label"), "fixture-model reasoning effort support");
assert.deepEqual(Array.from(select.children).map(option => option.textContent), ["Reasoning effort: Use reported", "Reasoning effort: Supported", "Reasoning effort: Not supported"]);
assert.deepEqual(Array.from(select.children).map(option => option.getAttribute("value")), ["", "on", "off"], "capability values unchanged");
const save = element("custom-pins-save"); assert.ok(save.classList.contains("ml-auto")); assert.equal(save.parentElement!.lastElementChild, save);
const help = element("custom-help"); assert.equal(element("custom-budget-help").parentElement, help); assert.equal(element("custom-reasoning-help").parentElement, help);
const children = Array.from(element("custom-endpoint-editor").children); assert.ok(children.indexOf(help) > children.indexOf(element("custom-pins")));
assert.match(help.textContent ?? "", /do not resize backend context/); assert.match(help.textContent ?? "", /Off may not disable/);
});
+18 -22
View File
@@ -6,7 +6,7 @@
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
* reducer (reduceDemoBridgeState)
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
* failure notes, inline sanitized svg
* failure notes, SVG image documents
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
* a fix iteration, untested-final verification, never-clean failure,
* no-Chrome failure)
@@ -28,6 +28,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { Value } from "typebox/value";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai";
import { renderToStaticMarkup } from "react-dom/server";
@@ -40,7 +41,7 @@ import {
INITIAL_DEMO_BRIDGE_STATE,
reduceDemoBridgeState,
} from "../components/artifacts/demo-card.tsx";
import { sanitizeSvg, svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
import { svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
import { slugFilename } from "../components/artifacts/download.ts";
import { chartSize } from "../components/session-overlay.tsx";
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
@@ -171,7 +172,11 @@ function fakeHeadless(opts: {
if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) {
throw new Error("XML parse error");
}
return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic
// The shared native image processor decodes renders, so a signature
// alone is no longer a successful fake raster.
const canvas = createCanvas(1, 1);
canvas.getContext("2d").fillRect(0, 0, 1, 1);
return canvas.toBuffer("image/png");
},
};
return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] };
@@ -274,14 +279,14 @@ describe("dispatch card rendering (the tool-call ack box)", () => {
assert.match(html, /Dispatched…/);
});
it("settles to a quiet delivered one-liner when done (artifact lives in the steer)", () => {
it("settles to a quiet finished one-liner when the worker is done", () => {
const html = renderToStaticMarkup(
<JobsContext.Provider value={[mkJob("done", "delivered")]}>
<JobCard part={ackPart as never} />
</JobsContext.Provider>,
);
assert.match(html, /Research agent/);
assert.match(html, /delivered/);
assert.match(html, /finished/);
assert.ok(!html.includes("web_search"), "no stale activity line once settled");
});
@@ -325,7 +330,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => {
assert.match(html, /pdf_render/);
});
it("pdf dispatch settles to the delivered one-liner", () => {
it("pdf dispatch settles to the finished one-liner", () => {
const pdfJob: JobRecord = ({
id: "jp1",
kind: "pdf",
@@ -342,7 +347,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => {
</JobsContext.Provider>,
);
assert.match(html, /PDF reader/);
assert.match(html, /delivered/);
assert.match(html, /finished/);
});
});
@@ -373,19 +378,19 @@ describe("steer card rendering (the delivered artifact)", () => {
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
});
it("renders the verified svg inline (sanitized); the maker's notes are glue and stay hidden", () => {
it("renders the verified svg as an image; the maker's notes are glue and stay hidden", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
const steer = buildSubagentSteer(
{ id: "jv", kind: "visual", toolCallId: "cv" },
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.ok(html.includes("circle"), "svg content rendered inline");
assert.ok(html.includes(encodeURIComponent(svg)), "the image URL contains the original SVG");
assert.match(html, /Figure/);
// Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the
// holder hugs + centers rather than stretching full-width.
assert.match(html, /w-fit/, "holder hugs the declared size");
assert.match(html, /svg\]:max-w-full/, "svg shrinks to fit only when oversized");
assert.match(html, /class="block h-auto max-w-full"/, "image shrinks to fit only when oversized");
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
});
@@ -590,17 +595,8 @@ describe("setSteerUiInverted (the persisted invert switch, 2026-10-07)", () => {
});
});
describe("sanitizeSvg", () => {
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => {
const dirty = '<svg width="10" height="10" onload="alert(1)"><script>alert(2)</script><circle r="1" onclick="pwn()"/><a href="javascript:alert(3)"><rect width="2" height="2"/></a></svg>';
const clean = sanitizeSvg(dirty);
assert.ok(!/<script/i.test(clean), "script tags removed");
assert.ok(!/\sonload/i.test(clean), "onload removed");
assert.ok(!/\sonclick/i.test(clean), "onclick removed");
assert.ok(!/javascript:/i.test(clean), "javascript: urls removed");
assert.ok(clean.includes("circle") && clean.includes("rect"), "geometry preserved");
});
});
// Generated-figure image boundaries are covered in svg-image.test.tsx.
// There is no longer a partial regex sanitizer in the display path.
// ─── 3. the demo-builder loop ────────────────────────────────────────────────
@@ -848,7 +844,7 @@ describe("demo/visual job runners", () => {
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered", "the fake deliver flips status like the wake pipeline");
assert.equal(done.activity, "finished", "the worker finishes independently of delivery");
const result = done.result as { title: string; html: string; tested: boolean; notes?: string };
assert.equal(result.title, "Compound interest");
assert.equal(result.html, GOOD_HTML);
+58 -6
View File
@@ -15,7 +15,7 @@ const mainRequire = createRequire(mainFile);
/** Decline the single-instance lock so normal boot never spawns a server.
* Then call the REAL createWindow with a stub Electron API and inspect its
* constructor options. No rewritten copy of the production logic. */
function windowOptions(platform: string) {
function windowOptions(platform: string, env: Record<string, string> = {}) {
class FakeWindow {
constructor(readonly options: unknown) {}
webContents = { on() {}, setWindowOpenHandler() {} };
@@ -23,13 +23,13 @@ function windowOptions(platform: string) {
once() {}
}
const electron = {
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} },
app: { setName() {}, getPath: () => "/fixture/LearnHarness", setPath() {}, requestSingleInstanceLock: () => false, quit() {} },
BrowserWindow: FakeWindow,
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = {
require: (id: string) => id === "electron" ? electron : mainRequire(id),
process: { platform, argv: [], env: {}, on() {} },
require: (id: string) => id === "electron" ? electron : id === "electron-updater" ? { autoUpdater: {} } : mainRequire(id),
process: { platform, argv: [], env, on() {} },
__dirname: dirname(mainFile),
};
runInNewContext(mainSource, context, { filename: mainFile });
@@ -61,6 +61,24 @@ describe("desktop window chrome", () => {
});
}
it("only the external dev shell receives the web subscription transport marker", () => {
assert.equal(windowOptions("darwin").webPreferences.additionalArguments, undefined);
assert.deepEqual(windowOptions("darwin", { LH_DEV_URL: "http://localhost:3000" }).webPreferences.additionalArguments, ["--lh-web-subscriptions"]);
});
it("dev preload routes opaque sign-in actions through its narrow external-browser channel", async () => {
let bridge!: { subscriptionTransport: string; openSubscriptionBrowser(action: string): Promise<void> };
const invoked: unknown[][] = [];
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin", argv: ["--lh-web-subscriptions"] },
require: () => ({ contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } }, ipcRenderer: { on() {}, invoke: async (...args: unknown[]) => { invoked.push(args); } } }),
});
assert.equal(bridge.subscriptionTransport, "web");
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
await bridge.openSubscriptionBrowser(action);
assert.deepEqual(invoked, [["lh:subscriptions:web-browser", action]]);
});
it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
let bridge: Record<string, unknown> = {};
const sent: unknown[][] = [];
@@ -73,7 +91,7 @@ describe("desktop window chrome", () => {
bridge = api;
},
},
ipcRenderer: { send: (...args: unknown[]) => sent.push(args) },
ipcRenderer: { on() {}, send: (...args: unknown[]) => sent.push(args) },
}),
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
@@ -81,12 +99,46 @@ describe("desktop window chrome", () => {
(bridge.setLanguage as (language: string) => void)("ru");
assert.deepEqual(sent, [["lh:language", "ru"]]);
assert.deepEqual(Object.keys(bridge).sort(), [
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "setLanguage", "subscriptions",
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "setLanguage", "subscriptions",
]);
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
});
});
describe("update preload bridge", () => {
it("buffers the menu request, unsubscribes safely, and uses the matching snapshot/check channels", async () => {
let bridge!: {
onOpenSettings: (cb: (section: string) => void) => () => void;
getUpdateState: () => Promise<unknown>;
checkForUpdates: () => Promise<void>;
};
const handlers = new Map<string, (event: unknown, value: string) => void>();
const invoked: string[] = [];
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin" },
require: () => ({
contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } },
ipcRenderer: {
on: (channel: string, listener: (event: unknown, value: string) => void) => handlers.set(channel, listener),
invoke: async (channel: string) => { invoked.push(channel); },
},
}),
});
const send = () => handlers.get("lh:open-settings")!({}, "general");
const seen: string[] = [];
send(); const off = bridge.onOpenSettings(section => seen.push(section));
assert.deepEqual(seen, ["general"], "pre-subscription request is delivered once");
send(); assert.deepEqual(seen, ["general", "general"]);
off(); send(); assert.equal(seen.length, 2);
const next: string[] = []; const offNext = bridge.onOpenSettings(section => next.push(section));
assert.deepEqual(next, ["general"]);
off(); send(); assert.equal(next.length, 2, "old cleanup cannot remove a newer subscriber");
offNext();
await bridge.getUpdateState(); await bridge.checkForUpdates();
assert.deepEqual(invoked, ["lh:update:get", "lh:update:check"]);
});
});
describe("desktop packaging policy", () => {
const yaml = require("js-yaml") as { load(source: string): Record<string, any> };
const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8"));
+390
View File
@@ -0,0 +1,390 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { finishDictation, useDictation, type Dictation } from "../components/dictation.tsx";
import { I18nProvider, useI18n } from "../components/i18n.tsx";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { isDictationActive, useDictationActive } from "../components/dictation-state.ts";
import type { GeneralSettings, UserSettings } from "../src/core/index.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
class Socket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static all: Socket[] = [];
readyState = 0;
sent: unknown[] = [];
constructor(public url: string) { super(); Socket.all.push(this); }
get language() { return new URL(this.url).searchParams.get("language"); }
open() { this.readyState = 1; this.dispatchEvent(new Event("open")); }
send(value: unknown) { this.sent.push(value); }
close() { this.readyState = 3; }
end(code = 1000) { this.close(); this.dispatchEvent(Object.assign(new Event("close"), { code })); }
final(text: string) {
this.dispatchEvent(Object.assign(new Event("message"), {
data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: text }] } }),
}));
}
}
class AudioGraph {
state = "running";
destination = {};
audioWorklet = { addModule: async () => {} };
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
}
class Worklet {
static latest: Worklet;
static autoFlush = true;
disconnected = false;
port = {
onmessage: (_event: { data: unknown }) => {},
postMessage: (_message: unknown) => { if (Worklet.autoFlush) queueMicrotask(() => this.flush()); },
};
constructor() { Worklet.latest = this; }
flush() {
this.port.onmessage({ data: { type: "audio", buffer: new Int16Array([7]).buffer } });
this.port.onmessage({ data: { type: "flushed" } });
}
connect() {}
disconnect() { this.disconnected = true; }
}
const globals = globalThis as Record<string, unknown>;
let restore: (() => void) | undefined;
let dispose: (() => Promise<void>) | undefined;
async function fixture(general: GeneralSettings = { sttLanguage: "ru" }) {
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
const state = {
// The contradictory upstream preference must never control recording.
record: { version: 1, general, dictation: { language: "pl" } } as UserSettings,
nextRead: undefined as Promise<Response> | undefined,
nextWrite: undefined as Promise<Response> | undefined,
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
nextToken: undefined as (() => Promise<Response>) | undefined,
readFails: false,
reads: 0,
active: false,
captures: 0,
stops: 0,
appends: [] as string[],
};
Socket.all = [];
Worklet.autoFlush = true;
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
React, window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent,
navigator: { userAgent: "node", languages: ["en-US"], mediaDevices: { getUserMedia: async () => {
state.captures++;
if (state.nextMic) return state.nextMic;
return { getTracks: () => [{ stop() { state.stops++; } }] };
} } },
WebSocket: Socket, AudioContext: AudioGraph, AudioWorkletNode: Worklet,
IS_REACT_ACT_ENVIRONMENT: true,
fetch: async (url: string, init?: RequestInit) => {
if (url === "/api/stt/token") return state.nextToken ? state.nextToken() : Response.json({ token: "test-token" });
if (url === "/api/settings") {
state.reads++;
if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; }
return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
}
assert.equal(url, "/api/settings/general");
assert.equal(init?.method, "PUT");
if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; }
state.record = { ...state.record, general: JSON.parse(init!.body as string) };
return Response.json({ settings: state.record });
},
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
restore = () => {
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globals[key];
}
};
const { createRoot } = await import("react-dom/client");
let latest!: Dictation;
let setting!: ReturnType<typeof useI18n>;
function Harness() {
latest = useDictation({ append: (text) => state.appends.push(text) });
setting = useI18n();
state.active = useDictationActive();
return null;
}
let root = createRoot(document.getElementById("root")!);
const mount = async () => {
await act(async () => { root.render(<I18nProvider initialGeneral={state.record.general}><Harness /></I18nProvider>); });
};
dispose = async () => { await act(async () => root.unmount()); };
return {
state,
get dictation() { return latest; },
get setting() { return setting; },
mount,
unmount: () => dispose!(),
remount: async () => { await dispose!(); root = createRoot(document.getElementById("root")!); await mount(); },
};
}
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
test("general language and hints are snapshotted; reconnect and final delivery retain them", async () => {
const f = await fixture({ sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"] });
await f.mount();
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.active, true);
const first = Socket.all[0]!;
assert.equal(first.language, "multi");
assert.deepEqual(new URL(first.url).searchParams.getAll("language_hint"), ["ru", "en"]);
await act(async () => { first.open(); first.final("Первое"); });
await act(async () => { await f.setting.saveGeneral({ sttLanguage: "en", sttPreferredLanguages: [] }); });
await act(async () => { first.end(1006); });
const retry = Socket.all[1]!;
assert.equal(retry.url, first.url);
await act(async () => { retry.open(); retry.final("second"); });
let finished = false;
let done!: Promise<void>;
await act(async () => { done = f.dictation.finish().then(() => { finished = true; }); });
assert.equal(finished, false);
assert.deepEqual(f.state.appends, []);
assert.equal(retry.sent.at(-1), '{"type":"CloseStream"}');
assert(retry.sent.at(-2) instanceof ArrayBuffer, "the final PCM frame precedes CloseStream");
await act(async () => { retry.final("last"); retry.end(); await done; });
assert.deepEqual(f.state.appends, ["Первое second last"]);
assert.equal(f.state.active, false);
await act(async () => { retry.final("stale"); retry.end(); });
assert.deepEqual(f.state.appends, ["Первое second last"]);
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[2]!.language, "en");
});
for (const [selection, expected] of [[undefined, "en"], ["en", "en"], ["ru", "ru"], ["multi", "multi"]] as const) {
test(`fork language ${selection ?? "system"} ignores contradictory dictation.language`, async () => {
const f = await fixture({ sttLanguage: selection });
await f.mount();
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, expected);
assert.equal(f.state.reads, 0, "recording does not load an independent preference");
});
}
test("a failed general save retains the confirmed language, including a shortcut during saving", async () => {
const f = await fixture(); await f.mount();
const write = deferred<Response>(); f.state.nextWrite = write.promise;
let saved!: Promise<void>;
await act(async () => { saved = f.setting.saveGeneral({ sttLanguage: "multi" }); saved.catch(() => {}); });
await act(async () => { window.dispatchEvent(new window.Event("lh:kbd-toggle-dictation")); });
assert.equal(Socket.all[0]!.language, "ru");
await act(async () => { write.resolve(Response.json({}, { status: 500 })); await assert.rejects(saved, /Could not save/); });
assert.equal(f.setting.sttLanguage, "ru");
});
test("confirmed general choices survive navigation and a failed refresh does not substitute upstream defaults", async () => {
const f = await fixture(); await f.mount();
await act(async () => { await f.setting.saveGeneral({ sttLanguage: "multi" }); });
await f.remount();
f.state.readFails = true;
await act(async () => { notifySettingsSaved("general"); });
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
assert.equal(f.dictation.error, null);
});
test("capture uses the confirmed general choice while a save is pending and the new one on the next run", async () => {
const f = await fixture(); await f.mount();
const write = deferred<Response>(); f.state.nextWrite = write.promise;
let saved!: Promise<void>;
await act(async () => { saved = f.setting.saveGeneral({ sttLanguage: "multi" }); });
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "ru");
f.state.record.general = { sttLanguage: "multi" };
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; });
await act(async () => { f.dictation.cancel(); f.dictation.toggle(); });
assert.equal(Socket.all[1]!.language, "multi");
});
test("a stale general refresh cannot overwrite a newer confirmed choice", async () => {
const f = await fixture(); await f.mount();
const read = deferred<Response>(); f.state.nextRead = read.promise;
await act(async () => { notifySettingsSaved("general"); });
await act(async () => { await f.setting.saveGeneral({ sttLanguage: "multi" }); });
await act(async () => { read.resolve(Response.json({ settings: { general: { sttLanguage: "ru" } } })); });
assert.equal(f.setting.sttLanguage, "multi");
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("cancel while microphone permission is pending prevents orphaned capture", async () => {
const f = await fixture(); await f.mount();
const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); f.state.nextMic = mic.promise;
await act(async () => { f.dictation.toggle(); f.dictation.cancel(); });
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
assert.equal(f.state.stops, 1);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
test("finish in the start turn cancels a pending microphone without hanging", async () => {
const f = await fixture(); await f.mount();
const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); f.state.nextMic = mic.promise;
await act(async () => { f.dictation.toggle(); await finishDictation(); });
assert.equal(f.dictation.phase, "idle");
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
assert.equal(f.state.stops, 1);
assert.equal(Socket.all.length, 0);
});
test("Stop while the socket connects drains PCM and waits for the final transcript", async () => {
const f = await fixture(); await f.mount();
await act(async () => { f.dictation.toggle(); });
const socket = Socket.all[0]!;
let done!: Promise<void>; let finished = false;
await act(async () => { done = finishDictation().then(() => { finished = true; }); });
assert.equal(f.dictation.phase, "stopping");
assert.equal(finished, false);
await act(async () => { socket.open(); });
assert.equal(f.dictation.phase, "stopping");
assert(socket.sent[0] instanceof ArrayBuffer);
assert.equal(socket.sent[1], '{"type":"CloseStream"}');
await act(async () => { socket.final("tail"); socket.end(); await done; });
assert.deepEqual(f.state.appends, ["tail"]);
});
test("a socket opening during drain cannot close the stream before the tail PCM", async () => {
const f = await fixture(); await f.mount(); Worklet.autoFlush = false;
await act(async () => { f.dictation.toggle(); });
const socket = Socket.all[0]!;
let done!: Promise<void>;
await act(async () => { done = finishDictation(); socket.open(); });
assert.equal(socket.sent.length, 0);
assert.equal(f.dictation.phase, "stopping");
await act(async () => { Worklet.latest.flush(); });
assert(socket.sent[0] instanceof ArrayBuffer);
assert.equal(socket.sent[1], '{"type":"CloseStream"}');
await act(async () => { socket.final("complete"); socket.end(); await done; });
assert.deepEqual(f.state.appends, ["complete"]);
});
test("a reconnect while stopping retains its session and still finishes exactly once", async () => {
const f = await fixture(); await f.mount();
await act(async () => { f.dictation.toggle(); });
const socket = Socket.all[0]!;
await act(async () => { socket.open(); socket.final("before"); });
let done!: Promise<void>;
await act(async () => { done = finishDictation(); });
await act(async () => { socket.end(1006); });
const retry = Socket.all[1]!;
assert.equal(retry.url, socket.url);
await act(async () => { retry.open(); });
assert.equal(retry.sent.at(-1), '{"type":"CloseStream"}');
assert.equal(f.dictation.phase, "stopping");
await act(async () => { retry.final("after"); retry.end(); await done; });
assert.deepEqual(f.state.appends, ["before after"]);
assert.equal(f.state.active, false);
});
test("cancel during drain resolves finish and ignores late worklet/socket messages", async () => {
const f = await fixture(); await f.mount(); Worklet.autoFlush = false;
await act(async () => { f.dictation.toggle(); });
await act(async () => { Socket.all[0]!.open(); Socket.all[0]!.final("discard"); });
let done!: Promise<void>;
await act(async () => { done = f.dictation.finish(); f.dictation.cancel(); await done; });
await act(async () => { Worklet.latest.flush(); Socket.all[0]!.final("late"); Socket.all[0]!.end(1006); });
assert.deepEqual(f.state.appends, []);
assert.equal(Socket.all.length, 1);
assert.equal(f.state.active, false);
});
test("unmount during a pending general refresh prevents orphaned capture", async () => {
const f = await fixture(); await f.mount();
const read = deferred<Response>(); f.state.nextRead = read.promise;
await act(async () => { notifySettingsSaved("general"); });
await f.unmount();
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
test("unmount releases an active stream/socket and discards deferred finals", async () => {
const f = await fixture(); await f.mount();
await act(async () => { f.dictation.toggle(); });
const socket = Socket.all[0]!;
await act(async () => { socket.open(); socket.final("do not append into another page"); });
await f.unmount();
assert.equal(f.state.stops, 1);
assert.equal(socket.readyState, 3);
assert.equal(isDictationActive(), false);
assert.deepEqual(f.state.appends, []);
await act(async () => { socket.final("late event"); socket.end(1006); });
assert.equal(Socket.all.length, 1);
assert.deepEqual(f.state.appends, []);
});
test("system STT follows the fork's detected Russian locale, not dictation.language", async () => {
const f = await fixture({});
Object.defineProperty(globalThis, "navigator", { configurable: true, value: {
userAgent: "node", languages: ["ru-RU"],
mediaDevices: { getUserMedia: async () => ({ getTracks: () => [{ stop() { f.state.stops++; } }] }) },
} });
await f.mount();
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "ru");
});
test("a failed token request releases capture and leaves an honest error", async () => {
const f = await fixture();
f.state.nextToken = async () => Response.json({ message: "token denied" }, { status: 500 });
await f.mount();
await act(async () => { f.dictation.toggle(); });
assert.equal(f.dictation.error, "token denied");
assert.equal(f.dictation.phase, "idle");
assert.equal(f.state.stops, 1);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
test("one reconnect is allowed and a second socket failure ends the run", async () => {
const f = await fixture(); await f.mount();
await act(async () => { f.dictation.toggle(); });
await act(async () => { Socket.all[0]!.open(); Socket.all[0]!.final("kept"); Socket.all[0]!.end(1006); });
assert.equal(Socket.all.length, 2);
await act(async () => { Socket.all[1]!.end(1006); });
assert.equal(f.dictation.error, "Deepgram connection lost (1006)");
assert.equal(f.dictation.phase, "idle");
assert.deepEqual(f.state.appends, ["kept"]);
assert.equal(f.state.stops, 1);
});
test("a missing drain acknowledgement fails visibly and resolves submit waiters", async () => {
const f = await fixture(); await f.mount(); Worklet.autoFlush = false;
await act(async () => { f.dictation.toggle(); });
await act(async () => { Socket.all[0]!.open(); });
let done!: Promise<void>;
await act(async () => { done = finishDictation(); });
await act(async () => { await done; });
assert.equal(f.dictation.error, "Could not finish microphone capture");
assert.equal(f.dictation.phase, "idle");
assert.equal(Worklet.latest.disconnected, true);
assert.equal(isDictationActive(), false);
});
test("microphone permission resolving after unmount immediately stops the late stream", async () => {
const f = await fixture(); await f.mount();
const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); f.state.nextMic = mic.promise;
await act(async () => { f.dictation.toggle(); });
await f.unmount();
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
assert.equal(f.state.stops, 1);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { after, describe, it } from "node:test";
import { DICTATION_LANGUAGES, deepgramListenUrl, isDictationLanguage } from "../src/core/dictation.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { parseDictationSettingsBody } from "../src/server/settings-body.ts";
describe("Nova-3 dictation language contract", () => {
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length);
for (const code of ["en", "fr", "ru", "sv", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) {
assert.ok(isDictationLanguage(code), code);
}
// Xhosa and Zulu are documented for multi, not monolingual Nova-3.
for (const code of ["xh", "zu", "en-CA", "auto", "xx", "pl&model=other", null, 3]) {
assert.equal(isDictationLanguage(code), false, String(code));
}
const labels = DICTATION_LANGUAGES.slice(1).map((item) => item.label);
assert.deepEqual(labels, [...labels].sort((a, b) => a.localeCompare(b, "en")));
});
it("only language varies in the streaming URL; all tuned parameters survive", () => {
for (const { code: language } of DICTATION_LANGUAGES) {
const url = new URL(deepgramListenUrl(language));
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
assert.deepEqual(Object.fromEntries(url.searchParams), {
model: "nova-3", encoding: "linear16", sample_rate: "16000", channels: "1",
smart_format: "true", punctuate: "true", endpointing: "300", language,
});
}
});
it("old records default to English and explicit preferences resolve without a migration", () => {
assert.equal(resolveSettings(null).dictation.language, "en");
assert.equal(resolveSettings({ version: 1 }).dictation.language, "en");
for (const language of ["pl", "multi", "en-GB"] as const) {
const record = sanitizeUserSettings({ version: 1, dictation: { language } });
assert.equal(record?.version, 1);
assert.equal(resolveSettings(record).dictation.language, language);
}
});
it("rejects invalid API values and drops invalid stored preferences", () => {
assert.deepEqual(parseDictationSettingsBody({}), { ok: true, settings: {} });
assert.deepEqual(parseDictationSettingsBody({ language: "multi" }), { ok: true, settings: { language: "multi" } });
for (const value of [null, [], "pl", { language: "xx" }, { language: null }, { language: 3 }]) {
assert.equal(parseDictationSettingsBody(value).ok, false);
}
for (const dictation of [null, [], "pl", { language: "xx" }, { language: 3 }]) {
const record = sanitizeUserSettings({ version: 1, dictation, general: { autoUpdate: false } });
assert.equal(resolveSettings(record).dictation.language, "en");
assert.equal(record?.general?.autoUpdate, false);
}
});
});
const testData = mkdtempSync(path.join(tmpdir(), "lh-dictation-settings-"));
process.env.LH_DATA_DIR = testData;
after(() => { rmSync(testData, { recursive: true, force: true }); });
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
it("the section route persists language independently and rejects unsupported values without writing", async () => {
const { PUT } = await import("../app/api/settings/[section]/route.ts");
writeSettings({ version: 1, general: { autoUpdate: false } });
const put = (language: string) => PUT(new Request("http://localhost/api/settings/dictation", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language }),
}), { params: Promise.resolve({ section: "dictation" }) });
assert.equal((await put("pl")).status, 200);
const saved = { version: 1, dictation: { language: "pl" }, general: { autoUpdate: false } };
assert.deepEqual(readSettings(), saved);
assert.equal((await put("unsupported")).status, 400);
assert.deepEqual(readSettings(), saved);
const general = await PUT(new Request("http://localhost/api/settings/general", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoUpdate: true }),
}), { params: Promise.resolve({ section: "general" }) });
assert.equal(general.status, 200);
assert.deepEqual(readSettings(), { ...saved, general: { autoUpdate: true } }, "auto-update cannot erase dictation language");
});
+254
View File
@@ -0,0 +1,254 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { setDictationActive } from "../components/dictation-state.ts";
import { I18nProvider, LanguageSelect, SttModelSelect, useI18n } from "../components/i18n.tsx";
import { AUTH_KEYS, type AuthKeyStatus, type ModelCatalogEntry, type UserSettings } from "../src/core/index.ts";
import { ModelMenuList } from "../components/settings/model-menu.tsx";
import { DictationLanguageSetting } from "../components/dictation-language.tsx";
let dispose: (() => Promise<void>) | undefined;
let restore: (() => void) | undefined;
const byTest = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
const trigger = () => byTest("language-select-sttLanguage");
const options = () => [...document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')];
async function click(element: Element | Document) { await act(async () => { element.dispatchEvent(new window.Event("click", { bubbles: true })); }); }
async function key(element: Element | Document, value: string) {
await act(async () => {
const event = new window.Event("keydown", { bubbles: true, cancelable: true });
Object.assign(event, { key: value }); element.dispatchEvent(event);
});
}
async function search(value: string, element: HTMLInputElement) {
await act(async () => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
element.dispatchEvent(new window.Event("input", { bubbles: true }));
});
}
async function fixture(failRead = false) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
Object.assign(doc, { oninput: null });
Object.assign(win, { innerWidth: 1280, innerHeight: 900 });
win.HTMLElement.prototype.focus = function () { active = this; };
win.HTMLElement.prototype.scrollIntoView = () => {};
win.HTMLElement.prototype.getBoundingClientRect = () => ({ top: 200, bottom: 230, left: 400, right: 500, width: 100, height: 30, x: 400, y: 200, toJSON() {} });
const state = {
record: { version: 1, general: { sttLanguage: "en" }, dictation: { language: "pl" } } as UserSettings,
writes: [] as string[], failWrite: false, outerEscapes: 0, failRead,
keys: [] as AuthKeyStatus[], nextWrite: undefined as Promise<Response> | undefined,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({
React, window: win, document: doc, Element: win.Element, HTMLElement: win.HTMLElement,
HTMLInputElement: win.HTMLInputElement, HTMLTextAreaElement: win.HTMLTextAreaElement,
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
navigator: { userAgent: "node", languages: ["en-US"] },
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
cancelAnimationFrame: clearTimeout,
fetch: async (url: string, init?: RequestInit) => {
if (url === "/api/keys") return Response.json({ keys: state.keys, providers: [] });
if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT");
state.writes.push(init!.body as string);
if (state.nextWrite) return state.nextWrite;
if (state.failWrite) return Response.json({}, { status: 500 });
state.record = { ...state.record, general: JSON.parse(init!.body as string) };
return Response.json({ settings: state.record });
},
})) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; });
const { createRoot } = await import("react-dom/client");
let root = createRoot(doc.getElementById("root")!);
let i18n!: ReturnType<typeof useI18n>;
function Observer() { i18n = useI18n(); return null; }
const render = async (element: React.ReactNode) => {
await act(async () => { root.render(<I18nProvider initialGeneral={state.record.general}><Observer />{element}</I18nProvider>); });
};
const mount = () => render(<DictationLanguageSetting />);
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
await mount();
return {
state, render, get i18n() { return i18n; },
remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); },
};
}
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
test("the shared language row exposes only fork system/en/ru/multi choices and saves general", async () => {
const f = await fixture();
assert.equal(trigger().textContent?.trim(), "English▾");
await click(trigger());
assert.deepEqual(options().map((option) => option.textContent?.trim().replace("✓", "").trim()), ["System default", "English", "Русский", "Multilingual"]);
assert.equal(options()[1]!.getAttribute("aria-checked"), "true");
await click(options()[2]!);
assert.equal(f.state.record.general?.sttLanguage, "ru");
assert.equal(f.state.record.dictation?.language, "pl", "the contradictory field is neither migrated nor updated");
assert.equal(trigger().textContent?.trim(), "Русский▾");
assert.equal(f.state.writes.length, 1);
});
test("System default removes the general override without introducing another default", async () => {
const f = await fixture(); await click(trigger()); await click(options()[0]!);
assert.equal(Object.hasOwn(f.state.record.general!, "sttLanguage"), false);
assert.match(trigger().textContent!, /System default/);
assert.equal(f.i18n.sttLanguage, "en");
});
test("the compact composer selector and settings row share one confirmed general choice", async () => {
const f = await fixture();
await f.render(<><DictationLanguageSetting /><LanguageSelect field="sttLanguage" compact /></>);
await click(trigger()); await click(options()[3]!);
assert.match(trigger().textContent!, /Multilingual/);
assert.match(byTest("language-select-sttLanguage-compact").textContent!, /Multi/);
await click(byTest("language-select-sttLanguage-compact")); await click(options()[1]!);
assert.equal(f.state.record.general?.sttLanguage, "ru");
assert.match(trigger().textContent!, /Русский/);
});
test("a settings read failure is visible on save and the existing control can retry", async () => {
const f = await fixture(true); await click(trigger()); await click(options()[2]!);
assert.match(document.body.textContent!, /Could not load language settings/);
assert.equal(f.state.writes.length, 0);
assert.equal(f.state.record.general?.sttLanguage, "en");
f.state.failRead = false; await click(trigger()); await click(options()[2]!);
assert.equal(f.state.record.general?.sttLanguage, "ru");
});
test("failed saves retain the confirmed choice and the same control can retry", async () => {
const f = await fixture(); f.state.failWrite = true;
await click(trigger()); await click(options()[3]!);
assert.match(document.body.textContent!, /Could not save language settings/);
assert.equal(f.state.record.general?.sttLanguage, "en");
assert.match(trigger().textContent!, /English/);
f.state.failWrite = false; await click(trigger()); await click(options()[3]!);
assert.equal(f.state.record.general?.sttLanguage, "multi");
});
test("an outstanding save locks the selector and releases it only after confirmation", async () => {
const f = await fixture(); let resolve!: (response: Response) => void;
f.state.nextWrite = new Promise<Response>((done) => { resolve = done; });
await click(trigger()); await click(options()[3]!);
assert.equal(trigger().disabled, true);
assert.match(trigger().textContent!, /English/);
f.state.record.general = { sttLanguage: "multi" };
await act(async () => { resolve(Response.json({ settings: f.state.record })); });
assert.equal(trigger().disabled, false);
assert.match(trigger().textContent!, /Multilingual/);
await f.remount(); assert.match(trigger().textContent!, /Multilingual/);
});
test("Escape and outside click close only the fork language menu", async () => {
await fixture(); await click(trigger());
await key(document, "Escape");
assert.equal(trigger().getAttribute("aria-expanded"), "false");
assert.equal(document.activeElement, trigger());
await click(trigger());
await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); });
assert.equal(trigger().getAttribute("aria-expanded"), "false");
});
test("capture locks the shared row and dismisses its open menu until cleanup", async () => {
await fixture(); await click(trigger());
assert.equal(trigger().getAttribute("aria-expanded"), "true");
await act(async () => { setDictationActive(true); });
assert.equal(trigger().disabled, true);
assert.equal(trigger().getAttribute("aria-expanded"), "false");
assert.equal(options().length, 0);
await act(async () => { setDictationActive(false); });
assert.equal(trigger().disabled, false);
});
test("the existing STT model selector retains both Deepgram and OpenRouter MAI", async () => {
const f = await fixture(); await f.render(<SttModelSelect />);
await click(byTest("stt-model-select"));
assert.deepEqual(options().map((option) => option.textContent?.replace("✓", "").trim()), ["Nova-3 · Deepgram", "MAI-Transcribe-2 · OpenRouter"]);
await click(options()[1]!);
assert.equal(f.state.record.general?.sttModel, "microsoft/mai-transcribe-2");
assert.equal(f.state.record.general?.sttLanguage, "en");
});
for (const source of ["none", "file", "env"] as const) test(`Dictation retains fork language controls for ${source}-owned keys, including editing`, async () => {
const f = await fixture();
f.state.keys = [{
id: "DEEPGRAM_API_KEY", info: AUTH_KEYS.find((key) => key.id === "DEEPGRAM_API_KEY")!,
present: source !== "none", masked: source === "none" ? null : "…test (32 chars)",
source, shadowedByEnv: false,
}];
const { KeysSection } = await import("../components/settings/keys-section.tsx");
await f.render(<KeysSection />);
const group = byTest("group-dictation");
const rows = group.lastElementChild!.children;
assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key");
assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language");
assert.equal(rows[2]!.getAttribute("data-testid"), "dictation-setup-hint");
assert(group.querySelector('[data-testid="row-dictation-language"] [data-testid="language-select-sttLanguage"]'));
assert.equal(group.querySelector('[data-testid="dictation-language"]'), null);
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.textContent, "Deepgram API key");
assert.equal(byTest("dictation-setup-hint").querySelectorAll("a").length, 1);
assert.match(rows[1]!.querySelector(".font-prose")!.className, /text-\(length:--lh-prose-size\)/);
assert.equal(trigger().disabled, false, "language is independent of key ownership");
await click(byTest("key-deepgram_api_key-row"));
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"));
assert.ok(byTest("dictation-setup-hint"), "guidance remains visible while the key is edited");
await click(trigger());
assert.equal(options().length, 4);
await key(document, "Escape");
assert.equal(options().length, 0);
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"), "field Escape does not cancel the key editor");
await click(trigger()); await click(options()[3]!);
assert.equal(f.state.record.general?.sttLanguage, "multi");
});
test("the shared model menu preserves ranking, provider identity, keyboard clamping, and row content", async () => {
const f = await fixture();
const model = (id: string, provider: string): ModelCatalogEntry => ({
id, provider, name: `Model ${id}`, contextWindow: 32000, maxTokens: 4096,
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
input: ["text"], reasoning: false, supportedLevels: [],
});
const models = [model("beta", "p1"), model("alpha", "p1"), model("alpha", "p2")];
const picked: ModelCatalogEntry[] = [];
await f.render(<ModelMenuList models={models} effectiveId="alpha" effectiveProvider="p2" onPick={(m) => picked.push(m)} footer="Model footer" />);
const modelInput = document.querySelector<HTMLInputElement>('[data-testid="model-search"]')!;
const options = () => [...document.querySelectorAll<HTMLButtonElement>('[data-testid="model-list"] button')];
assert.equal(document.activeElement, modelInput);
assert.match(options()[0]!.textContent!, /p1 · alpha/);
assert.match(options()[1]!.textContent!, /p2 · alpha/);
assert.ok(!options()[0]!.className.includes("bg-muted/60"));
assert.ok(options()[1]!.className.includes("bg-muted/60"));
assert.match(options()[0]!.textContent!, /\$1\.00\/\$2\.00/);
assert.match(options()[0]!.textContent!, /32k/);
assert.match(document.body.textContent!, /Model footer/);
await key(modelInput, "Enter");
assert.equal(picked[0], models[1], "Enter without walking selects the first ranked result");
await key(modelInput, "ArrowUp");
await key(modelInput, "Enter");
assert.equal(picked[1], models[0], "ArrowUp enters at the last result");
await key(modelInput, "ArrowDown");
await key(modelInput, "ArrowDown");
await key(modelInput, "Enter");
assert.equal(picked[2], models[0], "ArrowDown clamps at the last result");
await search("p2", modelInput);
assert.equal(options().length, 1);
await key(modelInput, "Enter");
assert.equal(picked[3], models[2], "search resets the walked index and includes provider identity");
await search("no-model-matches", modelInput);
await key(modelInput, "Enter");
assert.equal(picked.length, 4);
assert.match(document.body.textContent!, /No models match/);
});
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDictationLanguageStore } from "../components/dictation-preference.ts";
test("all readers immediately receive the confirmed write without another GET", async () => {
let reads = 0;
let notifications = 0;
let failReads = false;
const store = createDictationLanguageStore(async (_url, init) => {
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: JSON.parse(init.body as string) } });
reads++;
return failReads ? Response.json({}, { status: 500 }) : Response.json({ settings: null });
}, () => { notifications++; });
assert.equal(await store.load(), "en");
const observed: string[] = [];
const off = store.subscribe(() => { observed.push(`${store.getSnapshot().language}:${store.getSnapshot().saving}`); });
failReads = true;
assert.equal(await store.save("multi"), true);
assert.equal(await store.getLanguage(), "multi");
assert.equal(reads, 1);
assert.equal(notifications, 1);
assert(observed.includes("multi:true"));
assert.equal(observed.at(-1), "multi:false");
off();
});
test("ordinary loads and repeated recordings reuse the initial confirmed value", async () => {
let reads = 0;
const store = createDictationLanguageStore(async () => {
reads++;
if (reads > 1) throw new Error("should not reread");
return Response.json({ settings: { version: 1, dictation: { language: "fr" } } });
});
assert.deepEqual(await Promise.all([store.getLanguage(), store.load()]), ["fr", "fr"]);
for (let i = 0; i < 3; i++) {
const unsubscribe = store.subscribe(() => {});
assert.equal(await store.getLanguage(), "fr");
assert.equal(await store.load(), "fr");
unsubscribe();
}
assert.equal(reads, 1);
});
test("a failed external refresh invalidates the cache until a successful retry", async () => {
let fail = false;
const store = createDictationLanguageStore(async () => fail
? Response.json({}, { status: 500 })
: Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
await store.load();
fail = true;
await assert.rejects(store.load(true), /Could not load/);
await assert.rejects(store.getLanguage(), /Could not load/);
fail = false;
assert.equal(await store.getLanguage(), "pl");
assert.equal(store.getSnapshot().error, null);
});
test("stale reads cannot overwrite a newer write and subscribers see busy before transport runs", async () => {
let stale!: (response: Response) => void;
let nextRead: Promise<Response> | undefined;
const store = createDictationLanguageStore(async (_url, init) => {
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
if (nextRead) { const response = nextRead; nextRead = undefined; return response; }
return Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
});
await store.load();
nextRead = new Promise((resolve) => { stale = resolve; });
const oldRead = store.load(true);
await Promise.resolve();
let startedDuringPublish: Promise<string> | undefined;
const off = store.subscribe(() => {
if (store.getSnapshot().saving && !startedDuringPublish) startedDuringPublish = store.getLanguage();
});
assert.equal(await store.save("multi"), true);
stale(Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
assert.equal(await oldRead, "multi");
assert.equal(await startedDuringPublish, "multi");
assert.equal(store.getSnapshot().language, "multi");
off();
});
for (const newestFirst of [false, true]) test(`overlapping reads keep the newest value, newest resolves first: ${newestFirst}`, async () => {
const responses: ((response: Response) => void)[] = [];
const store = createDictationLanguageStore(() => new Promise((resolve) => { responses.push(resolve); }));
const first = store.load();
await Promise.resolve();
const second = store.load(true);
await Promise.resolve();
const old = Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
const fresh = Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
if (newestFirst) { responses[1]!(fresh); responses[0]!(old); }
else { responses[0]!(old); responses[1]!(fresh); }
assert.deepEqual(await Promise.all([first, second]), ["multi", "multi"]);
assert.equal(await store.getLanguage(), "multi");
});
test("capture also waits for a write initiated as a pending read publishes its result", async () => {
const store = createDictationLanguageStore(async (_url, init) => Response.json({ settings: {
version: 1, dictation: { language: init?.method === "PUT" ? "multi" : "en" },
} }));
let started = false;
const off = store.subscribe(() => {
if (!started && store.getSnapshot().language === "en") {
started = true;
void store.save("multi");
}
});
assert.equal(await store.getLanguage(), "multi");
off();
});
+259
View File
@@ -0,0 +1,259 @@
import React, { act, useLayoutEffect, useRef, useState } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { QuestionDock } from "../components/question-dock.tsx";
import { PracticeDock } from "../components/practice-dock.tsx";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
const observers: TestObserver[] = [];
class TestObserver {
elements = new Set<Element>();
disconnected = false;
constructor(readonly callback: () => void) { observers.push(this); }
observe(el: Element) { this.elements.add(el); }
disconnect() { this.disconnected = true; this.elements.clear(); }
}
Object.assign(globalThis, {
ResizeObserver: TestObserver,
getComputedStyle: (el: HTMLElement) => ({
borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px",
borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px",
paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px",
paddingBottom: "0px",
overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"),
}),
});
// Import after installing window so the hook uses its browser layout effect.
const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts");
const normal = {
columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0,
shellBorderHeight: 2, contentHeight: 800, questionHeight: 600,
questionPaddingHeight: 12,
};
describe("dockHeightBudget — whole dock, not just its question", () => {
it("subtracts answer controls, toolbar, borders and breathing gap", () => {
assert.deepEqual(dockHeightBudget(normal), {
viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false,
});
});
it("accounts for growing answer fields and the separate reading draft", () => {
const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 });
assert.equal(result.questionMaxHeight, 156);
assert.equal(result.shellMaxHeight, 438);
});
it("preserves a computed zero and deliberately selects whole-content scrolling", () => {
const result = dockHeightBudget({ ...normal, columnHeight: 292 });
assert.equal(result.questionMaxHeight, 0);
assert.equal(result.scrollWholeContent, true);
});
it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => {
assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true);
assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false);
});
it("never creates negative caps, even if the toolbar exceeds the column", () => {
const result = dockHeightBudget({ ...normal, columnHeight: 20 });
assert.equal(result.viewportMaxHeight, 0);
assert.equal(result.shellMaxHeight, 0);
assert.equal(result.questionMaxHeight, 0);
assert.equal(result.scrollWholeContent, true);
});
it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => {
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false);
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true);
});
});
// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout.
// Live-browser checks separately exercise actual field sizing and WAAPI.
const sizes: Record<string, number> = {};
let animating = false;
dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : [];
const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 });
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
dom.window.HTMLElement.prototype.getBoundingClientRect = function () {
const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part;
if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} };
return originalRect.call(this);
};
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } });
Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } });
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } });
const ignoreHeight = () => {};
function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: {
expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void;
onHeight?: (height: number) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const blockRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [inset, setInset] = useState(0);
useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight });
// Like DockOverlay's later morph/reserve layout effect: constraints must
// already be applied in this same commit, not an after-paint correction.
useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); });
return <><div ref={scrollRef} data-part="column" /><div ref={blockRef} data-part="block" data-inset={inset}>
<div ref={viewportRef} data-part="viewport"><div ref={bodyRef} data-part="body">
<div ref={shellRef} data-part="shell"><div ref={contentRef} data-part="content" key={practice ? "practice" : "question"}>
{practice ? <PracticeDock problemCallId="p1" input={{ statement: "Long practice problem" }} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
: <QuestionDock toolName="probe" toolCallId="q1" input={{ question: "Long teaching question", options: [{ label: "A" }, { label: "B" }], correctAnswer: "A", explanation: "A is correct." }} expanded={expanded} dictationClaim={false} onExpand={() => {}} onSubmit={() => {}} />}
</div></div>
</div></div><div data-testid="toolbar" />
</div></>;
}
async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps<typeof Fixture>) => Promise<void>) => Promise<void>) {
resetSizes();
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const render = async (props = {}) => { await act(async () => root.render(<Fixture {...props} />)); };
try { await run(host, render); }
finally { await act(async () => root.unmount()); host.remove(); }
}
const part = (host: HTMLElement, name: string) => host.querySelector<HTMLElement>(`[data-part="${name}"]`)!;
describe("measured question/practice DOM and observer lifecycle", () => {
it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => {
await withFixture(async (host, render) => {
await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
assert.equal(part(host, "block").dataset.inset, "10");
assert.equal(part(host, "viewport").style.overflowY, "visible");
assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]')));
});
});
it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => {
await withFixture(async (host, render) => {
await render();
Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 });
await act(async () => observers.at(-1)!.callback());
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
});
});
it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => {
await withFixture(async (host, render) => {
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 });
await render({ expanded: false });
assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted");
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
assert.equal(part(host, "viewport").style.overflowY, "visible");
resetSizes();
await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
});
});
it("reconnects to practice's newly mounted question after collapsing and revealing", async () => {
await withFixture(async (host, render) => {
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 });
await render({ practice: true, expanded: false });
const collapsedObserver = observers.at(-1)!;
assert.equal(host.querySelector('[data-dock-scroll-area]'), null);
resetSizes();
await render({ practice: true, expanded: true });
assert.equal(collapsedObserver.disconnected, true);
assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!));
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
});
});
it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => {
await withFixture(async (host, render) => {
sizes.column = 292;
await render();
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none");
assert.equal(part(host, "viewport").style.overflowY, "auto");
assert.equal(part(host, "viewport").style.maxHeight, "200px");
assert.equal(part(host, "shell").style.maxHeight, "none");
sizes.column = 700;
await act(async () => observers.at(-1)!.callback());
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
assert.equal(part(host, "viewport").style.overflowY, "visible");
});
});
it("removes constraints in composer mode and disconnects observers on unmount", async () => {
let last: TestObserver;
await withFixture(async (host, render) => {
await render();
await render({ enabled: false });
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
assert.equal(part(host, "shell").style.maxHeight || "", "");
last = observers.at(-1)!;
});
assert.equal(last!.disconnected, true);
});
it("reports child-only resize heights to the reserve, but never mid-morph values", async () => {
await withFixture(async (_host, render) => {
const heights: number[] = [];
await render({ onHeight: h => heights.push(h) });
assert.equal(heights.at(-1), 862);
animating = true;
try {
sizes.block = 900;
await act(async () => observers.at(-1)!.callback());
assert.equal(heights.at(-1), 862, "animated block height was not reserved");
} finally { animating = false; }
await act(async () => observers.at(-1)!.callback());
assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render");
});
});
it("reconnects to a new keyed mode even when expansion is unchanged", async () => {
await withFixture(async (host, render) => {
await render();
const previous = observers.at(-1)!;
const oldContent = part(host, "content");
await render({ practice: true });
assert.equal(previous.disconnected, true);
assert.notEqual(part(host, "content"), oldContent);
assert.ok(observers.at(-1)!.elements.has(part(host, "content")));
});
});
it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => {
await withFixture(async (host, render) => {
await render();
const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"),
body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") };
assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true));
});
});
});
describe("wheel routing — question, short-window content, then transcript", () => {
it("keeps native wheel input in the question and then the whole-content viewport", () => {
const root = document.createElement("div");
root.innerHTML = '<div data-viewport style="overflow-y:auto"><div data-question style="overflow-y:auto"><span>text</span></div></div>';
const viewport = root.firstElementChild as HTMLElement;
const question = viewport.firstElementChild as HTMLElement;
const transcript = document.createElement("div"); transcript.scrollTop = 100;
for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; }
const wheel = (deltaY: number, deltaMode = 0) => {
let prevented = false;
forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript);
return prevented;
};
assert.equal(wheel(-20), false, "question can scroll up");
question.scrollTop = 0;
assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge");
viewport.scrollTop = 0;
assert.equal(wheel(-20), true);
assert.equal(transcript.scrollTop, 80);
question.scrollTop = viewport.scrollTop = 300;
assert.equal(wheel(1, 1), true);
assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized");
viewport.style.overflowY = "visible"; viewport.scrollTop = 50;
assert.equal(wheel(-20), false, "question still consumes upward movement");
question.style.overflowY = "hidden";
assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers");
assert.equal(wheel(0), false);
});
});
+89
View File
@@ -0,0 +1,89 @@
import React from "react";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { FilePathChip, PathText } from "../components/file-path-chip.tsx";
import { quoteFilePath } from "../src/core/file-paths.ts";
function documentFor(node: React.ReactNode) {
return parseHTML(`<html><body>${renderToStaticMarkup(node)}</body></html>`).document;
}
describe("FilePathChip", () => {
it("shows a filename and decorative type icon with an accessible full path", () => {
const path = "/tmp/course notes/α.pdf";
const document = documentFor(<FilePathChip path={path} />);
const chip = document.body.firstElementChild!;
assert.equal(chip.tagName, "SPAN");
assert.equal(chip.textContent, "α.pdf");
assert.equal(chip.getAttribute("title"), path);
assert.equal(chip.getAttribute("role"), "img");
assert.equal(chip.getAttribute("aria-label"), path);
assert.equal(chip.querySelector("svg")!.getAttribute("aria-hidden"), "true");
assert.ok(chip.querySelector(".lucide-file-text"));
assert.equal(document.querySelector("a, button, input, [tabindex]"), null);
assert.equal(chip.getAttributeNames().some((name) => name.toLowerCase() === "contenteditable"), false);
assert.ok(chip.classList.contains("inline-flex"));
assert.ok(chip.querySelector(".truncate"));
});
it("supports atomic editor rendering without requiring an editor in transcripts", () => {
const document = documentFor(<FilePathChip path="C:\\notes\\chapter.txt" contentEditable={false} className="editor-chip" />);
const chip = document.body.firstElementChild!;
// React SSR emits contentEditable; linkedom preserves attribute case.
const editableAttribute = chip.getAttributeNames().find((name) => name.toLowerCase() === "contenteditable")!;
assert.equal(chip.getAttribute(editableAttribute), "false");
assert.ok(chip.classList.contains("editor-chip"));
assert.equal(chip.textContent, "chapter.txt");
});
it("chooses type icons without accessing files or changing their names", () => {
for (const [filename, icon] of [
["figure.PNG", "file-image"], ["lecture.mp3", "file-headphone"], ["clip.mp4", "file-play"],
["course.zip", "file-archive"], ["data.csv", "file-spreadsheet"], ["example.ts", "file-code"],
["notes.md", "file-text"], ["unknown.xyz", "file"],
]) {
const document = documentFor(<FilePathChip path={`/not/real/${filename}`} />);
assert.equal(document.body.textContent, filename);
assert.ok(document.querySelector(`.lucide-${icon}`));
}
});
it("keeps dangerous filename HTML inert in both label and title", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.pdf';
const document = documentFor(<FilePathChip path={path} />);
assert.equal(document.body.textContent, '<img src=x onerror="alert(1)">.pdf');
assert.equal(document.body.firstElementChild!.getAttribute("title"), path);
assert.equal(document.querySelector("img, script, [onerror], a"), null);
});
});
describe("PathText", () => {
it("keeps ordinary text, markup-looking text and inline code completely unchanged", () => {
const text = 'Ordinary <b>prose</b>\n`code` ./relative `file:///tmp/a` & words';
const document = documentFor(<PathText text={text} />);
assert.equal(document.body.textContent, text);
assert.equal(document.body.children.length, 0);
assert.equal(renderToStaticMarkup(<PathText text="" />), "");
});
it("renders all quoted platforms, adjacent words and paths containing backticks", () => {
const paths = ["/tmp/a.pdf", "C:\\notes\\b.md", "\\\\server\\share\\c.png", "~/d`e.txt"];
const text = `before${paths.map(quoteFilePath).join(" then ")}after`;
const document = documentFor(<PathText text={text} />);
const chips = document.querySelectorAll('[role="img"]');
assert.equal(chips.length, paths.length);
assert.deepEqual(Array.from(chips, (chip) => chip.getAttribute("title")), paths);
assert.equal(document.body.textContent, "beforea.pdf then b.md then c.png then d`e.txtafter");
assert.equal(text, `before${paths.map(quoteFilePath).join(" then ")}after`, "presentation never rewrites saved text");
});
it("does not interpret HTML in ordinary text or path labels", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.png';
const document = documentFor(<PathText text={`<script>alert(1)</script> ${quoteFilePath(path)}`} />);
assert.equal(document.querySelector("script, img, [onerror], a"), null);
assert.equal(document.querySelector('[role="img"]')!.getAttribute("title"), path);
assert.equal(document.body.textContent, '<script>alert(1)</script> <img src=x onerror="alert(1)">.png');
});
});
+233
View File
@@ -0,0 +1,233 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { FilePathDropZone } from "../components/file-path-drop-zone.tsx";
import { useDictationTarget } from "../components/dictation.tsx";
import { PathInput } from "../components/path-input.tsx";
import { readPathInput, restorePathSelection } from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** No native DataTransfer/Selection in linkedom. These shims exercise React's
* real bubbling handlers and the editor's logical caret contract, not OS drag. */
async function fixture({ desktop = true, claimed = true } = {}) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
const selection = {
anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0,
setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) {
Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset });
},
};
Object.assign(doc, { getSelection: () => selection, oninput: null });
win.HTMLElement.prototype.focus = function () { active = this; };
const resolved: File[] = [];
const backingPaths = new Map<File, string | Error>();
const appends: string[] = [];
let byteReads = 0;
let uploads = 0;
let submits = 0;
let keys = 0;
let value = "readthis";
const bridge = desktop ? { getPathForFile(file: File) {
resolved.push(file);
const result = backingPaths.get(file);
if (result instanceof Error) throw result;
return result ?? "";
} } : undefined;
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, next] of Object.entries({
React, window: win, document: doc, Node: win.Node, HTMLElement: win.HTMLElement, Element: win.Element,
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
fetch: () => { uploads++; throw new Error("No network expected for file drops"); },
})) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: next });
}
const oldBridge = Object.getOwnPropertyDescriptor(win, "lh");
Object.defineProperty(win, "lh", { configurable: true, value: bridge });
const { createRoot } = await import("react-dom/client");
const root = createRoot(doc.getElementById("root")!);
function Harness() {
useDictationTarget((text) => appends.push(text), claimed);
return <>
<aside data-testid="sidebar">Sidebar</aside>
<FilePathDropZone>
<form onSubmit={(event) => { event.preventDefault(); submits++; }}>
<PathInput data-testid="editor" value={value} onValueChange={(next) => { value = next; draw(); }} onKeyDown={() => { keys++; }} />
<button type="button" data-testid="elsewhere">Elsewhere</button>
<div data-testid="content">Content away from the input</div>
</form>
</FilePathDropZone>
</>;
}
function draw() { root.render(<Harness />); }
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { root.unmount(); });
if (oldBridge) Object.defineProperty(win, "lh", oldBridge);
else delete (win as unknown as Record<string, unknown>).lh;
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const editor = doc.querySelector<HTMLElement>('[data-testid="editor"]')!;
function file(name: string, path?: string | Error) {
const result = new File(["synthetic bytes must never be read"], name, { type: "application/pdf" });
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(result, method, { value: () => { byteReads++; throw new Error("No file content reads"); } });
}
if (path !== undefined) backingPaths.set(result, path);
return result;
}
function transfer(files: File[], directories: number[] = [], types = ["Files"]) {
return {
types,
files: Object.assign([...files], { item: (index: number) => files[index] ?? null }),
items: files.map((file, index) => ({ kind: "file", type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isDirectory: directories.includes(index), isFile: !directories.includes(index) }) })),
dropEffect: "none",
};
}
async function event(type: string, dataTransfer = transfer([]), target: Element = doc.querySelector('[data-testid="content"]')!) {
const event = Object.assign(new win.Event(type, { bubbles: true, cancelable: true }), { dataTransfer });
await act(async () => { target.dispatchEvent(event); });
return event;
}
return {
doc, editor, appends, resolved, file, transfer, event,
get value() { return value; },
get status() { return doc.querySelector('[role="status"]')?.textContent ?? ""; },
focusEditor(offset: number) {
editor.focus();
restorePathSelection(editor, { anchor: offset, focus: offset });
doc.dispatchEvent(new win.Event("selectionchange"));
},
assertPathOnly() { assert.equal(byteReads, 0); assert.equal(uploads, 0); assert.equal(submits, 0); assert.equal(keys, 0); },
};
}
test("desktop drop inserts quoted paths at the focused editor caret instead of the dictation claimant", async () => {
const f = await fixture();
f.focusEditor(4);
const paths = ["/actual/course notes.pdf", "C:\\course\\chapter`one.pdf", "\\\\host\\share\\図.png"];
const files = paths.map((path, index) => f.file(`display-name-${index}.pdf`, path));
const dropped = await f.event("drop", f.transfer(files));
assert.equal(dropped.defaultPrevented, true);
assert.equal(f.value, `read ${paths.map(quoteFilePath).join(" ")} this`);
assert.equal(readPathInput(f.editor), f.value);
assert.equal(f.doc.activeElement, f.editor);
assert.deepEqual(f.appends, [], "focused editor wins over the registered question field");
assert.deepEqual(f.resolved, files, "only Electron's full path, never File.name, enters the draft");
assert.equal(f.status, "", "successful file attachment is silent");
f.assertPathOnly();
});
test("a successful drop clears an earlier failure without replacing it with a confirmation bubble", async () => {
const f = await fixture();
await f.event("drop", f.transfer([f.file("virtual.pdf")]));
assert.match(f.status, /No local file paths were available/);
await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")]));
assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]);
assert.equal(f.doc.querySelector('[role="status"]'), null);
f.assertPathOnly();
});
test("unfocused drop uses the dictation target registry, not the last focused editor", async () => {
const f = await fixture();
f.focusEditor(4);
f.doc.querySelector<HTMLElement>('[data-testid="elsewhere"]')!.focus();
const paths = ["~/notes one.pdf", "/tmp/notes`final`"];
await f.event("drop", f.transfer(paths.map((path) => f.file("not-a-physical-path.pdf", path))));
assert.deepEqual(f.appends, [paths.map(quoteFilePath).join(" ")]);
assert.equal(f.value, "readthis", "saved editor caret must not override current default target");
f.assertPathOnly();
});
test("browser drop prevents navigation, explains desktop-only local paths and never infers File.name", async () => {
const f = await fixture({ desktop: false });
const transfer = f.transfer([f.file("/not/a/backing/path.pdf")]);
const enter = await f.event("dragenter", transfer);
assert.equal(enter.defaultPrevented, true);
assert.match(f.doc.querySelector('[data-testid="file-path-drop-indicator"]')!.textContent!, /desktop app/);
const over = await f.event("dragover", transfer);
assert.equal(over.defaultPrevented, true);
assert.equal(transfer.dropEffect, "copy");
const drop = await f.event("drop", transfer);
assert.equal(drop.defaultPrevented, true);
assert.match(f.status, /Local file paths can only be attached in the desktop app/);
assert.match(f.status, /paste a full path instead/);
assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null);
assert.equal(f.value, "readthis");
assert.deepEqual(f.appends, []);
assert.deepEqual(f.resolved, []);
f.assertPathOnly();
});
test("invalid, virtual and directory items are skipped while valid backing paths stay ordered", async () => {
const f = await fixture();
const files = [
f.file("virtual.pdf"), f.file("bad.pdf", "relative.pdf"), f.file("broken.pdf", new Error("not backed")),
f.file("folder", "/directory"), f.file("valid.pdf", "/existing/valid.pdf"),
f.file("multiline.pdf", "/a\nb.pdf"), f.file("NUL.pdf", "/a\0b.pdf"), f.file("url.pdf", "file:///a.pdf"),
];
await f.event("drop", f.transfer(files, [3]));
assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]);
assert.equal(f.resolved.includes(files[3]!), false, "directory metadata prevents resolving a folder");
assert.match(f.status, /Some dropped items had no available file path/);
f.assertPathOnly();
});
test("a drop with no backing paths or no destination has a truthful notice and stages nothing", async () => {
const f = await fixture({ claimed: false });
const missing = await f.event("drop", f.transfer([f.file("virtual.pdf")]));
assert.equal(missing.defaultPrevented, true);
assert.match(f.status, /No local file paths were available/);
await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")]));
assert.match(f.status, /Open a chat to attach file paths/);
assert.deepEqual(f.appends, []);
assert.equal(f.value, "readthis");
f.assertPathOnly();
});
test("non-file drags stay native; sidebar file drops prevent navigation without attaching", async () => {
const f = await fixture();
const plain = f.transfer([], [], ["text/plain"]);
for (const type of ["dragenter", "dragover", "drop"]) {
assert.equal((await f.event(type, plain)).defaultPrevented, false);
}
const sidebar = f.doc.querySelector('[data-testid="sidebar"]')!;
assert.equal((await f.event("drop", f.transfer([f.file("notes.pdf", "/existing/notes.pdf")]), sidebar)).defaultPrevented, true);
assert.deepEqual(f.resolved, []);
assert.deepEqual(f.appends, []);
assert.equal(f.status, "");
f.assertPathOnly();
});
test("nested enter/leave depth keeps the drop overlay until the zone is left; notice is dismissible", async () => {
const f = await fixture({ desktop: false });
const transfer = f.transfer([f.file("notes.pdf")]);
await f.event("dragenter", transfer);
await f.event("dragenter", transfer);
await f.event("dragleave", transfer);
assert.ok(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'));
await f.event("dragleave", transfer);
assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null);
await f.event("drop", transfer);
assert.notEqual(f.status, "");
await act(async () => { f.doc.querySelector<HTMLElement>('[aria-label="Dismiss attachment notice"]')!.click(); });
assert.equal(f.status, "");
f.assertPathOnly();
});
test("production AppShell places the sidebar outside its content-only drop zone", () => {
const source = readFileSync(new URL("../components/sidebar.tsx", import.meta.url), "utf8");
const shell = source.slice(source.indexOf("export function AppShell("));
assert.match(shell, /<SessionsSidebar\s*\/>\s*<FilePathDropZone\b[^>]*>\{children\}<\/FilePathDropZone>/);
assert.doesNotMatch(shell, /<FilePathDropZone\b[^>]*>[\s\S]*<SessionsSidebar/);
});
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { runInNewContext } from "node:vm";
interface PathBridge {
platform: string;
getPathForFile(file: unknown): string;
[key: string]: unknown;
}
/** Execute the actual preload in a VM, without Electron, IPC or app startup. */
function fixture(resolve: (file: unknown) => string) {
let bridge!: PathBridge;
const supplied: unknown[] = [];
const sent: unknown[][] = [];
const invoked: unknown[][] = [];
const imports: string[] = [];
const exposed: string[] = [];
const electron = {
contextBridge: { exposeInMainWorld(name: string, surface: PathBridge) { exposed.push(name); bridge = surface; } },
webUtils: { getPathForFile(file: unknown) { supplied.push(file); return resolve(file); } },
ipcRenderer: {
on() {}, removeListener() {},
send: (...args: unknown[]) => { sent.push(args); },
invoke: (...args: unknown[]) => { invoked.push(args); return Promise.resolve(); },
},
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin", argv: [] },
require: (name: string) => { imports.push(name); assert.equal(name, "electron"); return electron; },
});
return { bridge, supplied, sent, invoked, imports, exposed, electron };
}
describe("path-only preload bridge", () => {
it("forwards the exact supplied File identity to webUtils, returning only the backing path without IPC", () => {
const file = new File(["synthetic fixture"], "display-name.pdf", { type: "application/pdf" });
let reads = 0;
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(file, method, { value: () => { reads++; throw new Error("Must not read file content"); } });
}
const fullPath = "/actual/course/notes with `backticks`.pdf";
const f = fixture((supplied) => { assert.equal(supplied, file); return fullPath; });
assert.deepEqual(f.exposed, ["lh"]);
assert.deepEqual(f.imports, ["electron"]);
assert.equal(f.bridge.getPathForFile(file), fullPath);
assert.equal(f.supplied.length, 1);
assert.equal(f.supplied[0], file);
assert.notEqual(fullPath, file.name);
assert.equal(reads, 0);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("keeps Electron's empty result for a File without an OS backing path, never substituting its name", () => {
const virtual = new File(["virtual"], "/misleading/full/path.pdf");
const f = fixture(() => "");
assert.equal(f.bridge.getPathForFile(virtual), "");
assert.equal(f.supplied[0], virtual);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("preserves webUtils errors for invalid File arguments instead of inventing a path", () => {
const invalid = { name: "/misleading/filename.pdf", path: "/not/a/capability.pdf" };
const error = new TypeError("Expected a File");
const f = fixture((supplied) => { assert.equal(supplied, invalid); throw error; });
assert.throws(() => f.bridge.getPathForFile(invalid), (caught) => caught === error);
assert.equal(f.supplied[0], invalid);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("adds just the narrow path resolver to the explicit surface, without exposing Electron or filesystem APIs", () => {
const f = fixture(() => "");
assert.deepEqual(Object.keys(f.bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom",
"installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "setLanguage", "subscriptions",
]);
assert.equal(f.bridge.platform, "darwin");
for (const name of ["webUtils", "ipcRenderer", "electron", "fs", "require", "readFile", "writeFile", "uploadFile", "openPath"]) {
assert.equal(Object.hasOwn(f.bridge, name), false, name);
}
assert.notEqual(f.bridge.getPathForFile, f.electron.webUtils.getPathForFile, "webUtils itself is not exposed");
});
it("retains context isolation, sandbox and disabled Node integration in the production window", () => {
const main = readFileSync(new URL("../electron/main.cjs", import.meta.url), "utf8");
assert.match(main, /webPreferences:\s*\{\s*contextIsolation:\s*true,\s*sandbox:\s*true,\s*nodeIntegration:\s*false,/);
assert.match(main, /preload:\s*path\.join\(__dirname,\s*"preload\.cjs"\)/);
});
});
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { filePathLabel, parseFilePaths, quoteFilePath } from "../src/core/file-paths.ts";
function paths(text: string) {
return parseFilePaths(text).flatMap((part) => part.kind === "path" ? [part.path] : []);
}
function assertLossless(text: string) {
const parts = parseFilePaths(text);
assert.equal(parts.map((part) => part.text).join(""), text);
let offset = 0;
for (const part of parts) {
assert.equal(part.start, offset);
assert.equal(text.slice(part.start, part.end), part.text);
offset = part.end;
}
assert.equal(offset, text.length);
}
describe("plain-text file path tokens", () => {
it("returns typed tokens with exact original text and UTF-16 offsets", () => {
const text = "📚 Read `/tmp/my notes.pdf` now.";
assert.deepEqual(parseFilePaths(text), [
{ kind: "text", text: "📚 Read ", start: 0, end: 8 },
{ kind: "path", text: "`/tmp/my notes.pdf`", path: "/tmp/my notes.pdf", start: 8, end: 27 },
{ kind: "text", text: " now.", start: 27, end: 32 },
]);
assert.deepEqual(parseFilePaths(""), []);
});
it("accepts rooted POSIX, drive, UNC and home paths without filesystem access", () => {
for (const path of [
"/tmp/notes.pdf", "/", "/does not exist/α ∂ 🧠.pdf", "//volume/file",
"C:\\Users\\Learner\\My notes.pdf", "d:/notes/測度.md", "C:\\",
"\\\\server\\share\\nested folder\\notes.pdf", "\\\\server\\share", "\\\\server/share/file.txt",
"~/Documents/notes.pdf", "~/", "/tmp/a\\b.txt", "/tmp/<>&\"'[]()#?!;:$*=|.txt",
]) {
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
});
it("recognizes manual and pasted quoted paths anywhere, including adjacent prose", () => {
const text = "word`/a`word (`~/b`) [`C:/c`](x) <`/d`> /not/quoted";
assert.deepEqual(paths(text), ["/a", "~/b", "C:/c", "/d"]);
assertLossless(text);
});
it("does not reinterpret ordinary prose, relative paths, URLs or code", () => {
for (const text of [
"Read /tmp/file.pdf and ~/notes", "`hello world`", "`a + b`", "`notes.pdf`", "`./notes.pdf`",
"`../notes.pdf`", "`C:notes.pdf`", "`~someone/notes.pdf`", "`file:///tmp/notes.pdf`",
"`https://example.test/a.pdf`", "`\\notes.pdf`", "`\\\\server`", "`const p = '/a';`",
"` /leading space`", "``code `/a` here``", "```\n/a\n```", "```\n`/a`\n```",
]) {
assert.deepEqual(paths(text), [], text);
assertLossless(text);
}
});
it("matches complete equal delimiter runs, never their partial boundaries", () => {
for (const text of ["`/a", "``/a`", "`/a``", "```/a``"]) {
assert.deepEqual(paths(text), []);
assertLossless(text);
}
// Other-width runs are legal filename characters, not partial closers.
assert.deepEqual(paths("``/a```/b``"), ["/a```/b"]);
assert.deepEqual(paths("`/a``/b`"), ["/a``/b"]);
assert.deepEqual(paths("``not code closed? `/a`"), ["/a"]);
assert.deepEqual(paths("```/a```"), ["/a"]);
});
it("preserves backticks with variable delimiters and one-space padding", () => {
assert.equal(quoteFilePath("/tmp/a`b.txt"), "``/tmp/a`b.txt``");
assert.equal(quoteFilePath("/tmp/a`"), "`` /tmp/a` ``");
assert.equal(quoteFilePath("/tmp/a```"), "```` /tmp/a``` ````");
for (const path of ["/tmp/a`", "/tmp/a``b`", "/tmp/`/```/``", "/tmp/a `", "/tmp/a ", "/tmp/ "]) {
const text = `before ${quoteFilePath(path)} after`;
assert.deepEqual(paths(text), [path]);
assertLossless(text);
}
assert.deepEqual(paths("`` /tmp/a` ``"), ["/tmp/a`"]);
assert.deepEqual(paths("` /tmp/a `"), ["/tmp/a "]);
assert.deepEqual(paths("` /tmp/a `"), [], "strip one padding space only");
});
it("rejects all C0/C1 controls, multiline paths and Unicode line separators", () => {
const controls = [
...Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)),
...Array.from({ length: 33 }, (_, i) => String.fromCharCode(127 + i)),
"\u2028", "\u2029",
];
for (const control of controls) {
const path = `/tmp/a${control}b.pdf`;
assert.deepEqual(paths(`\`${path}\``), []);
assert.throws(() => quoteFilePath(path), TypeError);
}
for (const path of ["", "relative", "file:///tmp/a", "C:a", "\\\\server"]) {
assert.throws(() => quoteFilePath(path), TypeError);
}
});
it("round-trips many combinations of legal filename punctuation and whitespace", () => {
for (const piece of ["`", "``", " ``` ", " ", " ", "<script>", "[link](x)", "a\\b", "Ω📚"]) {
for (const suffix of ["", "`", "``", " ", ".pdf"]) {
const path = `/tmp/${piece}${suffix}`;
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
}
});
});
describe("filePathLabel", () => {
it("extracts basenames for each platform without normalizing the path", () => {
for (const [path, label] of [
["/tmp/a.pdf", "a.pdf"], ["~/測度 theory.md", "測度 theory.md"],
["C:\\Users\\a.pdf", "a.pdf"], ["C:/Users/a.pdf", "a.pdf"],
["\\\\server\\share\\a.pdf", "a.pdf"], ["/tmp/a\\b.txt", "a\\b.txt"],
["/tmp/folder/", "folder"], ["/", "/"], ["~/", "~/"], ["C:\\", "C:\\"],
["/tmp/a`b", "a`b"],
]) assert.equal(filePathLabel(path!), label);
});
});
+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}
@@ -0,0 +1,90 @@
[
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo321f7_4202fae0",
"source": "messages.1.content.1.arguments.explanation",
"model": "google/gemini-3.7-flash",
"kind": "percent-at-display-end",
"raw": "To find the posterior probability $P(D \\\\mid +)$, we apply Bayes' theorem:\n$$\nP(D \\\\mid +) = \\\\frac{P(D \\\\cap +)}{P(+)} = \\\\frac{P(D)P(+ \\\\mid D)}{P(D)P(+ \\\\mid D) + P(\\\\neg D)P(+ \\\\mid \\\\neg D)}\n$$\n\nGiven:\n- $P(D) = 0.01$ (and thus $P(\\\\neg D) = 0.99$)\n- $P(+ \\\\mid D) = 0.90$\n- $P(+ \\\\mid \\\\neg D) = 0.05$\n\nEvaluating the numerator and denominator:\n$$\nP(D \\\\cap +) = 0.01 \\\\times 0.90 = 0.009\n$$\n$$\nP(\\\\neg D \\\\cap +) = 0.99 \\\\times 0.05 = 0.0495\n$$\n$$\nP(+) = 0.009 + 0.0495 = 0.0585\n$$\n\nTherefore:\n$$\nP(D \\\\mid +) = \\\\frac{0.009}{0.0585} = \\\\frac{9}{58.5} = \\\\frac{2}{13} \\\\approx 15.4\\\\%\n$$"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo321f7_4202fae0",
"source": "messages.5.content.1.arguments.explanation",
"model": "google/gemini-3.7-flash",
"kind": "percent-at-display-end",
"raw": "Because the two tests are conditionally independent given disease status:\n$$\nP(T_1 = +, T_2 = + \\\\mid D) = P(T_1 = + \\\\mid D) \\\\cdot P(T_2 = + \\\\mid D) = 0.90 \\\\times 0.90 = 0.81\n$$\n$$\nP(T_1 = +, T_2 = + \\\\mid \\\\neg D) = P(T_1 = + \\\\mid \\\\neg D) \\\\cdot P(T_2 = + \\\\mid \\\\neg D) = 0.10 \\\\times 0.10 = 0.01\n$$\n\nNow calculate the joint probabilities:\n$$\nP(D \\\\cap T_1 = + \\\\cap T_2 = +) = P(D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid D) = 0.01 \\\\times 0.81 = 0.0081\n$$\n$$\nP(\\\\neg D \\\\cap T_1 = + \\\\cap T_2 = +) = P(\\\\neg D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid \\\\neg D) = 0.99 \\\\times 0.01 = 0.0099\n$$\n\nTotal probability of two positive tests:\n$$\nP(T_1 = +, T_2 = +) = 0.0081 + 0.0099 = 0.0180\n$$\n\nApplying Bayes' theorem:\n$$\nP(D \\\\mid T_1 = +, T_2 = +) = \\\\frac{0.0081}{0.0180} = \\\\frac{81}{180} = \\\\frac{9}{20} = 45\\\\%\n$$"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo4op90_74572548",
"source": "messages.1.content.2.arguments.explanation",
"language": "pt",
"model": "deepseek/deepseek-v4.1-flash",
"kind": "percent-at-display-end",
"raw": "Em $10\\\\,000$ pessoas: $100$ estão doentes e $99$ dessas testam positivo; $9\\\\,900$ estão saudáveis e $5\\\\%$ delas, isto é $495$, testam positivo. Total de positivos: $99 + 495 = 594$. Entre os positivos, a fração de doentes é\\n$$\\n\\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%\\n$$\\nA base pequena de doentes faz os falsos positivos ($495$) dominarem os verdadeiros positivos ($99$)."
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo4op90_74572548",
"source": "messages.3.content.2.arguments.explanation",
"language": "pt",
"model": "deepseek/deepseek-v4.1-flash",
"kind": "percent-at-display-end",
"raw": "Continua valendo a mesma estrutura de Bayes, agora com números diferentes. Em $10\\\\,000$ pessoas: $200$ doentes, das quais $90\\\\%$ testam positivo $= 180$; $9\\\\,800$ saudáveis, das quais $5\\\\%$ testam positivo $= 490$ falsos positivos. Total de positivos: $180 + 490 = 670$.\\n$$\\nP(\\\\text{doente} \\\\mid +) = \\\\frac{180}{670} \\\\approx 26{,}9\\\\%\\n$$\\nNote que a especificidade $95\\\\%$ entra na conta pela sua taxa de falso positivo $1 - 0{,}95 = 0{,}05$, pesada por $P(\\\\text{saudável}) = 0{,}98$."
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo8kgtc_65767ffd",
"source": "messages.5.content.1.arguments.question",
"language": "es",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "document-math-framing",
"raw": "Una enfermedad afecta al $1\\%$ de una población. La prueba tiene una sensibilidad del $99\\%$ (probabilidad de dar positivo si la enfermedad está presente) y una tasa de falsos positivos del $5\\%$ (probabilidad de dar positivo si no está presente). Una persona al azar da positivo. ¿Cuál es la probabilidad de que realmente tenga la enfermedad?\\n\\n$$\\nP(\\text{enf} \\mid +) \\;=\\; ?$$"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo91klg_5332fa79",
"source": "messages.3.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "条件概率把样本空间缩小到条件所指定的那一部分。已知\"检测阳性\",人群就从 $1000$ 人缩到 $140$ 人,其中患病的有 $45$ 人,所以\\n$$\\nP(\\\\text{患病}\\\\mid\\\\text{阳性})=\\\\frac{P(\\\\text{患病}\\\\cap\\\\text{阳性})}{P(\\\\text{阳性})}=\\\\frac{45/1000}{140/1000}=\\\\frac{45}{140}\\\\approx 32.1\\\\%.\\n$$\\n另外三个数各有各的含义:$\\\\frac{45}{50}=90\\\\%$ 是 $P(\\\\text{阳性}\\\\mid\\\\text{患病})$(敏感度,条件方向反了);$\\\\frac{45}{1000}=4.5\\\\%$ 是联合概率 $P(\\\\text{患病}\\\\cap\\\\text{阳性})$(没做条件化);$\\\\frac{140}{1000}=14\\\\%$ 是 $P(\\\\text{阳性})$(不分是否患病的总阳性率)。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo91klg_5332fa79",
"source": "messages.7.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "用 $10000$ 人(或直接用全概率公式)算分母:\\n$$\\n\\\\begin{aligned}\\n\\\\text{患病且阳性}&=10000\\\\times 0.01\\\\times 0.99=99,\\\\\\\\\\n\\\\text{未患病但阳性}&=10000\\\\times 0.99\\\\times 0.05=495.\\n\\\\end{aligned}\\n$$\\n阳性总数 $=99+495=594$,所以\\n$$\\nP(D\\\\mid +)=\\\\frac{99}{594}\\\\approx 16.7\\\\%.\\n$$\\n各错误选项对应的具体失误:$99\\\\%$ 是把敏感度 $P(+\\\\mid D)$ 当成了 $P(D\\\\mid +)$(条件方向颠倒);$95.2\\\\%=\\\\frac{0.99}{0.99+0.05}$ 是把阳性率和假阳性率直接当人数用,漏掉了基础发生率的加权($99\\\\%$ 的人未患病,所以少量假阳性也架不住人多);$1\\\\%$ 是看到了阳性结果却不更新概率。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo91klg_5332fa79",
"source": "messages.9.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "两次阳性后的后验,用同样的公式但似然要平方:\\n$$\\n\\\\begin{aligned}\\nP(++\\\\mid D)&=0.99^2=0.9801,\\\\\\\\\\nP(++\\\\mid D')&=0.05^2=0.0025.\\n\\\\end{aligned}\\n$$\\n$$\\nP(D\\\\mid ++)\\\\frac{0.99^2\\\\times 0.01}{0.99^2\\\\times 0.01+0.05^2\\\\times 0.99}=\\\\frac{0.009801}{0.009801+0.002475}\\\\approx 79.8\\\\%.\\n$$\\n关键在于**两条分支都要平方**:患病分支的似然变成 $0.99^2$,未患病分支的假阳性率变成 $0.05^2$($5\\%$ 是很小的数,平方后变得更小,所以假阳性这一侧收缩得比患病侧更狠,后验从 $16.7\\\\%$ 跳到约 $80\\\\%$)。其他选项的失误:$98\\\\%$ 是把 $P(++\\\\mid D)$ 当成了 $P(D\\\\mid ++)$;$16.5\\\\%$ 只对患病分支用了两次独立,未患病分支仍用单次假阳性率;$33\\\\%$ 是把单次后验直接乘 $2$——两次阳性提供的证据不是相加的,而要在似然层面相乘。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo9c830_3866e4a6",
"source": "messages.7.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "anthropic/claude-haiku-5.5",
"kind": "percent-at-display-end",
"raw": "阳性总人数:真阳性 $100\\\\times0.9=90$,假阳性 $9900\\\\times0.05=495$,合计 $585$。\n\n所求的是阳性者中患病者的占比,分母是全部阳性者:\n$$\n\\\\frac{90}{585}\\\\approx15.4\\\\%\n$$\n\n$90\\\\%$ 是患病者中阳性的比例,分母错成了患病者;$84.6\\\\%$ 是阳性者中健康者的占比,回答的是另一个问题;$5\\\\%$ 是健康者中的假阳性率,分母是健康人群。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo9nu8w_dcc7add9",
"source": "messages.3.content.1.arguments.explanation",
"language": "id",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "Pakai aturan Bayes dengan menghitung dulu kejadian gabungan dalam satu kelompok besar, misalnya $10.000$ orang:\\n\\n- Sakit dan positif: $10.000 \\\\times 0{,}01 \\\\times 0{,}99 = 99$ orang.\\n- Sehat dan positif: $10.000 \\\\times 0{,}99 \\\\times 0{,}05 = 495$ orang.\\n\\nSemua yang hasilnya positif: $99 + 495 = 594$ orang. Maka\\n$$\\nP(\\\\text{sakit}\\\\mid +) = \\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%.\\n$$\\n\\nYang menipu di sini adalah base rate: kelompok sehat jauh lebih besar, sehingga meski hanya 5% dari mereka yang salah positif, jumlahnya tetap 5 kali lipat jumlah positif yang benar. Nilai $95{,}2\\\\%$ muncul kalau kelompok sakit dan sehat dianggap sama besar ($\\\\frac{99}{99+5}$); nilai $99\\\\%$ muncul kalau arah kondisinya dibalik, membaca $P(+\\\\mid \\\\text{sakit})$ sebagai $P(\\\\text{sakit}\\\\mid +)$."
}
]
+135
View File
@@ -0,0 +1,135 @@
{
"notice": "Unmodified model-authored fields from the frozen multilingual corpus. oldDisplay records the diagnosed application damage, not an expected output.",
"cases": [
{
"run": "pilot-20261025-01",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 1,
"chatId": "chat_mvo1rzjs_056eac45",
"source": "messages.5.content.1.arguments.explanation",
"raw": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result.",
"oldDisplay": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result."
},
{
"run": "pilot-20261025-01",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 1,
"chatId": "chat_mvo1sdnj_8f2d8c5e",
"source": "messages.9.content.1.arguments.explanation",
"raw": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.",
"oldDisplay": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$."
},
{
"run": "pilot-20261025-01",
"model": "anthropic/claude-haiku-5.5",
"language": "en",
"replicate": 1,
"chatId": "chat_mvo30cfe_57114723",
"source": "messages.3.content.1.arguments.explanation",
"raw": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term.",
"oldDisplay": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term."
},
{
"run": "multilingual-20261025-01-zh-Hans-r1",
"model": "openai/gpt-6-luna",
"language": "zh-Hans",
"replicate": 1,
"chatId": "chat_mvo60bsn_1cbbe884",
"source": "messages.3.content.1.arguments.question",
"raw": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?",
"oldDisplay": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?"
},
{
"run": "multilingual-20261025-01-zh-Hans-r1",
"model": "xiaomi/mimo-v2.6-flash",
"language": "zh-Hans",
"replicate": 1,
"chatId": "chat_mvo66u6w_a233fc16",
"source": "messages.9.content.2.arguments.explanation",
"raw": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。",
"oldDisplay": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。"
},
{
"run": "multilingual-20261025-01-id-r1",
"model": "openai/gpt-6-luna",
"language": "id",
"replicate": 1,
"chatId": "chat_mvo6q2lq_6af14437",
"source": "messages.7.content.1.arguments.explanation",
"raw": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$.",
"oldDisplay": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$."
},
{
"run": "multilingual-20261025-01-id-r1",
"model": "z-ai/glm-5.3-flash",
"language": "id",
"replicate": 1,
"chatId": "chat_mvo77f3x_135f8d66",
"source": "messages.7.content.2.arguments.explanation",
"raw": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$.",
"oldDisplay": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$."
},
{
"run": "multilingual-20261025-01-en-r2",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 2,
"chatId": "chat_mvo7ei0l_9263330e",
"source": "messages.3.content.1.arguments.question",
"raw": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?",
"oldDisplay": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?"
},
{
"run": "multilingual-20261025-01-en-r2",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 2,
"chatId": "chat_mvo7exl3_e1fe93cd",
"source": "messages.1.content.1.arguments.explanation",
"raw": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid.",
"oldDisplay": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid."
},
{
"run": "multilingual-20261025-01-en-r2",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 2,
"chatId": "chat_mvo7exl3_e1fe93cd",
"source": "messages.3.content.1.arguments.explanation",
"raw": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.",
"oldDisplay": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$."
},
{
"run": "multilingual-20261025-01-pt-r2",
"model": "openai/gpt-6-luna",
"language": "pt",
"replicate": 2,
"chatId": "chat_mvo7wn6c_d63ef3f1",
"source": "messages.7.content.1.arguments.explanation",
"raw": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível.",
"oldDisplay": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível."
},
{
"run": "multilingual-20261025-01-es-r2",
"model": "openai/gpt-6-luna",
"language": "es",
"replicate": 2,
"chatId": "chat_mvo8j89a_f6fc1cc0",
"source": "messages.3.content.1.arguments.explanation",
"raw": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$.",
"oldDisplay": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$."
},
{
"run": "multilingual-20261025-01-id-r2",
"model": "openai/gpt-6-luna",
"language": "id",
"replicate": 2,
"chatId": "chat_mvo9mesu_dbb56f1f",
"source": "messages.9.content.1.arguments.explanation",
"raw": "Untuk $\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$.",
"oldDisplay": "Untuk $\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$."
}
]
}
@@ -0,0 +1,19 @@
{
"provenance": "multilingual-20261025-01; original collection findings, not the failed r1 rendering",
"cases": [
{
"chatId": "chat_mvo6kxzl_540b570e",
"source": "messages.1.content.2.arguments.explanation",
"model": "z-ai/glm-5.3-flash",
"raw": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\\\cdot3+2\\\\cdot0 & 1\\\\cdot1+2\\\\cdot2\\\\\\\\ 0\\\\cdot3+1\\\\cdot0 & 0\\\\cdot1+1\\\\cdot2\\\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。",
"oldDisplay": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\cdot3+2\\cdot0 & 1\\cdot1+2\\cdot2\\\\\\\\ 0\\cdot3+1\\cdot0 & 0\\cdot1+1\\cdot2\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。"
},
{
"chatId": "chat_mvo9oquq_74bfd7f6",
"source": "messages.3.content.1.arguments.explanation",
"model": "xiaomi/mimo-v2.6-flash",
"raw": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\\\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\\\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\\\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\\\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\\\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif.",
"oldDisplay": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif."
}
]
}
+5 -1
View File
@@ -657,6 +657,7 @@ describe("graph ledger store", () => {
import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts";
import { deleteChatSession, sessionCoverage } from "../src/server/chats.ts";
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { jobStore } from "../src/store/jobs.ts";
import {
@@ -811,11 +812,13 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
const turnedAway = await deleteChatSession(dA.id, "delete-nodes");
assert.deepEqual(turnedAway, { ok: false, status: 409, code: "turn_in_flight", error: "A turn is running in this session — try again when it finishes", jobIds: [] });
endTurn(dA.id);
// 409 — running background jobs.
// 409 — running background jobs; a refused delete keeps its notice.
rememberTurnFailure(dA.id, "The reply could not be saved.");
const job = jobStore.createJob({ kind: "research", chatId: dA.id, toolCallId: "del-call-1" });
const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes");
assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409");
if (jobsBlocked.code === "jobs_running") assert.deepEqual(jobsBlocked.jobIds, [job.id]);
assert.match(await settledTurnResponse(dA.id).text(), /The reply could not be saved\./);
assert.equal(jobStore.deleteJobsForChat(dA.id), 1, "test cleanup — the job file is gone");
// Success — a terminal job rides along; the chat record dies last.
@@ -830,6 +833,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.ok(outcome.graphRevision > 0, "the version stamp moved");
}
assert.equal(chatStore.getChat(dA.id), null, "the chat record is gone");
assert.doesNotMatch(await settledTurnResponse(dA.id).text(), /"type":"error"/, "deletion clears its transient notice");
assert.equal(jobStore.getJob(doneJob.id), null, "the chat's job records are gone");
// And a second call is an honest 404.
assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" });
+10
View File
@@ -0,0 +1,10 @@
/** Synthetic authorized records, imported only after each test's isolated env. */
import { randomBytes } from "node:crypto";
import type { CustomProviderSettings } from "../../src/core/settings.ts";
import { persistCustomAuth } from "../../src/server/custom-provider-auth.ts";
export function authorizeFixture(cp: CustomProviderSettings, fileKey?: string): CustomProviderSettings {
const id = randomBytes(16).toString("hex");
persistCustomAuth({ version: 1, id, baseUrl: cp.baseUrl, authMode: cp.authMode ?? "keyless", ...(fileKey ? { fileKeyBaseUrl: cp.baseUrl } : {}) }, fileKey);
return { ...cp, credentialBindingId: id };
}
+56
View File
@@ -0,0 +1,56 @@
/** Child-process fixture: owned profile only; every upstream fetch is synthetic. */
import fs from "node:fs";
import { AUTH_FILE, SETTINGS_FILE } from "../../config.ts";
const [mode, action = "key", fault = "none"] = process.argv.slice(2);
const requests: { url: string; authorization: string | null }[] = [];
globalThis.fetch = async (url, init) => {
requests.push({ url: String(url), authorization: new Headers(init?.headers).get("authorization") });
if (String(url).endsWith("/models")) return Response.json({ data: [{ id: "m" }] });
return new Response('data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } });
};
const rename = fs.renameSync;
const write = fs.writeFileSync;
const read = fs.readFileSync;
let authRenames = 0;
fs.renameSync = (...args: Parameters<typeof rename>) => {
if (args[1] === AUTH_FILE) {
authRenames++;
if (fault === "auth-before-rename" || (fault === "double-io" && authRenames > 1)) throw new Error("fixture auth I/O failure");
rename(...args);
if (fault === "after-auth") process.exit(77);
return;
}
return rename(...args);
};
fs.writeFileSync = (...args: Parameters<typeof write>) => {
if (args[0] === SETTINGS_FILE && fault === "double-io") throw new Error("fixture settings I/O failure");
if (args[0] === SETTINGS_FILE && fault === "settings-landed") { write(...args); throw new Error("fixture ambiguous settings completion"); }
return write(...args);
};
fs.readFileSync = ((...args: Parameters<typeof read>) => {
if (args[0] === AUTH_FILE && fault === "unreadable") throw Object.assign(new Error("fixture unreadable auth"), { code: "EACCES" });
return read(...args);
}) as typeof read;
await import("../../src/server/keys.ts");
if (mode === "inspect") {
const { piModels, piEngineForSpec } = await import("../../src/agent/models.ts");
const { normalizeContext } = await import("@earendil-works/pi-ai");
const enabled = Boolean(piModels().getProvider("custom"));
let reason: string | undefined;
try {
const engine = piEngineForSpec({ provider: "custom", id: "m" });
reason = (await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "fixture", timestamp: 0 }] }), {}).result()).stopReason;
} catch { reason = "refused"; }
const { keyStatuses } = await import("../../src/server/keys.ts");
console.log(JSON.stringify({ enabled, reason, requests, statusIds: keyStatuses().map(row => row.id), metadataInEnv: process.env.__learnharness_custom_provider_binding !== undefined }));
} else {
const route = await import("../../app/api/custom-provider/route.ts");
const get = route.GET(new Request("http://localhost/api/custom-provider"));
const payload = action === "keyless" ? { baseUrl: "http://second.invalid/v1", authMode: "keyless" }
: action === "envMove" ? { baseUrl: "http://second.invalid/v1", authMode: "apiKey", reuseKey: true }
: action === "reuse" ? { baseUrl: "http://first.invalid/v1", authMode: "apiKey", reuseKey: true }
: { baseUrl: "http://second.invalid/v1", authMode: "apiKey", key: "fixture-new-key" };
const request = new Request("http://localhost/api/custom-provider", { method: action === "disconnect" ? "DELETE" : "POST", headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get.headers.get("etag")! }, body: JSON.stringify(payload) });
const result = await route.POST(request);
console.log(JSON.stringify({ status: result.status, body: await result.json(), requests }));
}
+5
View File
@@ -120,6 +120,11 @@ describe("translation quality and rendering", () => {
assert.equal(countLabel("en", 5, "proposition", "propositions"), "5 propositions");
assert.equal(translate("en", "· read {count} pages", { count: 1 }), "· read 1 page");
});
it("localizes the approved background-job completion wording", () => {
assert.equal(translate("en", "finished"), "finished");
assert.equal(translate("ru", "finished"), "завершено");
assert.equal(translate("ru", "failed"), "ошибка");
});
it("localizes canonical protocol errors without changing provider diagnostics or parameters", () => {
assert.equal(translate("ru", "No OpenAI key is set — add one in Settings → Keys"), "Ключ OpenAI не задан — добавьте его в разделе Настройки → Провайдеры");
assert.equal(translate("ru", "Save failed (503)"), "Не удалось сохранить (503)");
+357
View File
@@ -0,0 +1,357 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { Worker } from "node:worker_threads";
import { createCanvas } from "@napi-rs/canvas";
import * as photon from "@silvia-odwyer/photon-node";
import {
detectSupportedImageMimeType,
detectSupportedImageMimeTypeFromFile,
processImage,
} from "../src/agent/image-processing/index.ts";
import { applyExifOrientation } from "../src/agent/image-processing/exif-orientation.mjs";
import { resizeImageInProcess } from "../src/agent/image-processing/resize-core.mjs";
import { resizeImage } from "../src/agent/image-processing/resize.ts";
// Synthetic, offline fixtures only. Canvas generates inputs; Photon is the
// actual processing implementation and supplies independent encoding oracles.
function png(width = 96, height = 96, noise = false): Buffer {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
if (noise) {
const pixels = context.createImageData(width, height);
let state = 0x12345678;
for (let i = 0; i < pixels.data.length; i += 4) {
for (let channel = 0; channel < 3; channel++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
pixels.data[i + channel] = state >>> 24;
}
pixels.data[i + 3] = 255;
}
context.putImageData(pixels, 0, 0);
} else {
context.fillStyle = "#33aadd";
context.fillRect(0, 0, width, height);
}
return canvas.toBuffer("image/png");
}
function encodings(bytes: Uint8Array, width: number, height: number, qualities = [80, 85, 70, 55, 40]) {
const image = photon.PhotonImage.new_from_byteslice(bytes);
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
try {
return [
{ mimeType: "image/png", data: Buffer.from(resized.get_bytes()).toString("base64") },
...qualities.map((quality) => ({ mimeType: "image/jpeg", data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64") })),
];
} finally {
resized.free();
image.free();
}
}
function bmp(): Buffer {
// A 2x1, uncompressed, 24-bit Windows BMP (padded scanline).
const bytes = Buffer.alloc(62);
bytes.write("BM");
bytes.writeUInt32LE(bytes.length, 2);
bytes.writeUInt32LE(54, 10);
bytes.writeUInt32LE(40, 14);
bytes.writeInt32LE(2, 18);
bytes.writeInt32LE(1, 22);
bytes.writeUInt16LE(1, 26);
bytes.writeUInt16LE(24, 28);
bytes.writeUInt32LE(8, 34);
bytes.set([0, 0, 255, 0, 255, 0], 54);
return bytes;
}
function exifJpeg(orientation: number): Buffer {
const image = photon.PhotonImage.new_from_byteslice(png(12, 8));
let jpeg: Buffer;
try {
jpeg = Buffer.from(image.get_bytes_jpeg(80));
} finally {
image.free();
}
const exif = Buffer.alloc(36);
exif.set([0xff, 0xe1, 0, 34]);
exif.write("Exif\0\0", 4, "binary");
exif.write("II", 10);
exif.writeUInt16LE(42, 12);
exif.writeUInt32LE(8, 14);
exif.writeUInt16LE(1, 18);
exif.writeUInt16LE(0x0112, 20);
exif.writeUInt16LE(3, 22);
exif.writeUInt32LE(1, 24);
exif.writeUInt16LE(orientation, 28);
return Buffer.concat([jpeg.subarray(0, 2), exif, jpeg.subarray(2)]);
}
test("already-fit image bytes are unchanged, MIME aliases normalize, and transfer leaves input intact", async () => {
const bytes = png();
const original = Buffer.from(bytes);
const result = await processImage(bytes, " Image/PNG; charset=binary ");
assert.deepEqual(result, { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
assert.deepEqual(bytes, original);
const jpeg = exifJpeg(1);
assert.deepEqual(await processImage(jpeg, "image/jpg"), { ok: true, data: jpeg.toString("base64"), mimeType: "image/jpeg", hints: [] });
});
test("native default dimensions are 2000x2000 and dimension hints use the native mapping", async () => {
const bytes = png(12000, 24);
const result = await processImage(bytes, "image/png");
assert.ok(result.ok);
assert.equal(result.mimeType, "image/png");
assert.deepEqual(result.hints, ["[Image: original 12000x24, displayed at 2000x4. Multiply coordinates by 6.00 to map to original image.]"]);
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
try {
assert.equal(decoded.get_width(), 2000);
assert.equal(decoded.get_height(), 4);
} finally { decoded.free(); }
const tall = await resizeImage(png(24, 12000), "image/png");
assert.ok(tall);
assert.equal(tall.width, 4);
assert.equal(tall.height, 2000);
});
test("model dimensions use sequential rounding, not a one-step scale", async () => {
const result = await resizeImage(png(301, 503), "image/png", { maxWidth: 101, maxHeight: 100 });
assert.ok(result);
// 301x503 -> 101x169 -> 60x100.
assert.equal(result.width, 60);
assert.equal(result.height, 100);
assert.equal(result.data, encodings(png(301, 503), 60, 100)[0]!.data);
const rounding = await resizeImage(png(50, 77), "image/png", { maxWidth: 10, maxHeight: 10 });
assert.ok(rounding);
assert.equal(rounding.width, 7); // round(100 / round(77 * 10 / 50)); one-step gives 6.
assert.equal(rounding.height, 10);
});
test("native cap is strict and measures base64 bytes, not binary bytes", async () => {
const source = png(24, 24);
// A valid PNG with ignored trailing bytes: under 4.5 MiB binary, exactly
// 4.5 MiB base64. At equality the unchanged fast path must NOT run.
const padded = Buffer.alloc((4.5 * 1024 * 1024 / 4) * 3);
source.copy(padded);
assert.ok(padded.length < 4.5 * 1024 * 1024);
assert.equal(padded.toString("base64").length, 4.5 * 1024 * 1024);
const exact = await processImage(padded, "image/png");
assert.ok(exact.ok);
assert.notEqual(exact.data, padded.toString("base64"));
assert.ok(exact.data.length < 4.5 * 1024 * 1024);
assert.equal(exact.hints.length, 1, "native re-encoding is marked resized even at identical dimensions");
const justBelow = padded.subarray(0, padded.length - 3);
const below = await processImage(justBelow, "image/png");
assert.ok(below.ok);
assert.equal(below.data, justBelow.toString("base64"));
assert.deepEqual(below.hints, []);
});
test("PNG wins first under cap even when JPEG is smaller", async () => {
const bytes = png(96, 96, true);
const candidates = encodings(bytes, 95, 95);
assert.ok(candidates[0]!.data.length > candidates[1]!.data.length);
const result = await resizeImage(bytes, "image/png", { maxWidth: 95, maxBytes: candidates[0]!.data.length + 1 });
assert.ok(result);
assert.equal(result.mimeType, "image/png");
assert.equal(result.data, candidates[0]!.data);
});
test("JPEG quality selection is native first-fit [80,85,70,55,40], not smallest output", async () => {
const bytes = png(96, 96, true);
const candidates = encodings(bytes, 96, 96);
assert.ok(candidates[0]!.data.length > candidates[2]!.data.length);
assert.ok(candidates[2]!.data.length > candidates[1]!.data.length);
assert.ok(candidates[1]!.data.length > candidates[3]!.data.length);
const at80 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length + 1 });
assert.ok(at80);
assert.equal(at80.data, candidates[1]!.data);
// Strict equality excludes 80; 85 is larger, so 70 is the next fit.
const at70 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length });
assert.ok(at70);
assert.equal(at70.data, candidates[3]!.data);
assert.equal(at70.width, 96);
assert.equal(at70.height, 96);
for (const [index, quality] of [[4, 55], [5, 40]] as const) {
const candidate = candidates[index]!;
const cap = candidate.data.length + 1;
assert.ok(candidates.slice(0, index).every((earlier) => earlier.data.length >= cap));
const lowerQuality = await resizeImage(bytes, "image/png", { maxBytes: cap });
assert.ok(lowerQuality);
assert.equal(lowerQuality.data, candidate.data, `native quality ${quality} wins when preceding candidates are over cap`);
assert.equal(lowerQuality.width, 96);
assert.equal(lowerQuality.height, 96);
}
const override = await resizeImage(bytes, "image/png", { jpegQuality: 85, maxBytes: candidates[2]!.data.length + 1 });
assert.ok(override);
assert.equal(override.data, candidates[2]!.data, "caller quality precedes the native remaining quality steps");
});
test("all encodings over cap reduce each dimension by floor(0.75), eventually omitting at 1x1", async () => {
const bytes = png(97, 95, true);
const full = encodings(bytes, 97, 95);
const next = encodings(bytes, 72, 71);
const maxBytes = Math.min(...full.map((candidate) => candidate.data.length));
const expected = next.find((candidate) => candidate.data.length < maxBytes);
assert.ok(expected, "synthetic fixture fits on the first reduced dimensions");
const result = await resizeImage(bytes, "image/png", { maxBytes });
assert.ok(result);
assert.equal(result.width, 72);
assert.equal(result.height, 71);
assert.equal(result.mimeType, expected.mimeType);
assert.equal(result.data, expected.data);
assert.equal(await resizeImage(bytes, "image/png", { maxBytes: 1 }), null);
const omitted = await processImage(bytes, "image/png", { resizeOptions: { maxBytes: 1 } });
assert.deepEqual(omitted, { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
});
test("BMP converts to PNG and reports the native conversion hint", async () => {
const bytes = bmp();
assert.equal(detectSupportedImageMimeType(bytes), "image/bmp");
const result = await processImage(bytes, "IMAGE/BMP");
assert.ok(result.ok);
assert.equal(result.mimeType, "image/png");
assert.equal(detectSupportedImageMimeType(Buffer.from(result.data, "base64")), "image/png");
assert.deepEqual(result.hints, ["[Image converted from image/bmp to image/png.]"]);
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
try {
assert.equal(decoded.get_width(), 2);
assert.equal(decoded.get_height(), 1);
} finally { decoded.free(); }
});
test("EXIF-oriented dimensions govern resizing, with native unchanged-byte behavior when already fit", async () => {
for (const orientation of [2, 3, 4, 5, 6, 7, 8]) {
const bytes = exifJpeg(orientation);
const untouched = await resizeImage(bytes, "image/jpeg");
assert.ok(untouched);
assert.equal(untouched.data, bytes.toString("base64"));
assert.equal(untouched.originalWidth, orientation >= 5 ? 8 : 12);
assert.equal(untouched.originalHeight, orientation >= 5 ? 12 : 8);
const resized = await resizeImage(bytes, "image/jpeg", { maxWidth: 6, maxHeight: 6 });
assert.ok(resized);
assert.equal(resized.width, orientation >= 5 ? 4 : 6);
assert.equal(resized.height, orientation >= 5 ? 6 : 4);
}
});
test("EXIF mirrors and rotations preserve exact pixel placement for JPEG and both WebP EXIF layouts", () => {
const expected = [
[1, 2, 3, 4, 5, 6], // unchanged
[3, 2, 1, 6, 5, 4], // horizontal mirror
[6, 5, 4, 3, 2, 1], // 180 degrees
[4, 5, 6, 1, 2, 3], // vertical mirror
[1, 4, 2, 5, 3, 6], // transpose
[4, 1, 5, 2, 6, 3], // 90 degrees clockwise
[6, 3, 5, 2, 4, 1], // transverse
[3, 6, 2, 5, 1, 4], // 90 degrees counterclockwise
];
for (let orientation = 1; orientation <= 8; orientation++) {
const jpeg = exifJpeg(orientation);
// Only metadata is needed here: decoding is covered by the resize test.
const exif = jpeg.subarray(6, 38);
for (const metadata of [jpeg, ...[exif, exif.subarray(6)].map((data) => {
// Odd-size RIFF chunk before EXIF verifies native even-byte padding.
const webp = Buffer.alloc(12 + 10 + 8 + data.length);
webp.write("RIFF", 0);
webp.writeUInt32LE(webp.length - 8, 4);
webp.write("WEBP", 8);
webp.write("JUNK", 12);
webp.writeUInt32LE(1, 16);
webp.write("EXIF", 22);
webp.writeUInt32LE(data.length, 26);
data.copy(webp, 30);
return webp;
})]) {
const pixels = Uint8Array.from([1, 2, 3, 4, 5, 6].flatMap((value) => [value, 0, 0, 255]));
const raw = new photon.PhotonImage(pixels, 3, 2);
const oriented = applyExifOrientation(photon, raw, metadata);
try {
assert.equal(oriented.get_width(), orientation >= 5 ? 2 : 3);
assert.equal(oriented.get_height(), orientation >= 5 ? 3 : 2);
assert.deepEqual(Array.from(oriented.get_raw_pixels()).filter((_, index) => index % 4 === 0), expected[orientation - 1]);
} finally {
if (oriented !== raw) oriented.free();
raw.free();
}
}
}
});
test("corrupt supported input is omitted, unsupported conversion fails, and explicit autoResizeImages=false retains native bypass", async () => {
const bytes = Buffer.from("not an image");
assert.deepEqual(await processImage(bytes, "image/png"), { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
assert.deepEqual(await processImage(bytes, "image/bmp"), { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" });
assert.deepEqual(await processImage(bytes, "image/png", { autoResizeImages: false }), { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
const wide = png(12000, 24);
const bypass = await processImage(wide, "image/png", { autoResizeImages: false });
assert.ok(bypass.ok);
assert.equal(bypass.data, wide.toString("base64"));
const converted = await processImage(bmp(), "image/bmp", { autoResizeImages: false });
assert.ok(converted.ok);
assert.equal(converted.mimeType, "image/png");
});
test("MIME sniffing matches native supported signatures and rejects APNG/JPEG-LS/malformed BMP", async () => {
assert.equal(detectSupportedImageMimeType(png()), "image/png");
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xf7])), null);
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF89a")), "image/gif");
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF87a")), "image/gif");
assert.equal(detectSupportedImageMimeType(Buffer.from("RIFFxxxxWEBP")), "image/webp");
assert.equal(detectSupportedImageMimeType(Buffer.from("BM")), null);
assert.equal(detectSupportedImageMimeType(Buffer.from("not an image")), null);
const truncatedPng = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
assert.equal(detectSupportedImageMimeType(truncatedPng), null);
// MIME sniffing, like Pi, walks chunk headers without checking CRCs.
const source = png(2, 2);
const acTL = Buffer.alloc(20);
acTL.writeUInt32BE(8, 0);
acTL.write("acTL", 4);
const apng = Buffer.concat([source.subarray(0, 33), acTL, source.subarray(33)]);
assert.equal(detectSupportedImageMimeType(apng), null);
const badBmp = bmp();
badBmp.writeUInt16LE(2, 26);
assert.equal(detectSupportedImageMimeType(badBmp), null);
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-processing-"));
try {
await writeFile(path.join(dir, "renamed.txt"), source);
await writeFile(path.join(dir, "animated.png"), apng);
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "renamed.txt")), "image/png");
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "animated.png")), null);
await assert.rejects(detectSupportedImageMimeTypeFromFile(path.join(dir, "missing")), { code: "ENOENT" });
} finally { await rm(dir, { recursive: true, force: true }); }
});
test("unbundled Node worker responds with tagged output and matches the in-process fallback", async () => {
const bytes = png(96, 96, true);
const options = { maxWidth: 64, maxHeight: 64, maxBytes: 4000 };
const worker = new Worker(path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs"));
try {
const reply = new Promise<{ type: string; result: unknown }>((resolve, reject) => {
worker.once("message", resolve);
worker.once("error", reject);
});
worker.postMessage({ inputBytes: new Uint8Array(bytes), mimeType: "image/png", options });
assert.deepEqual(await reply, { type: "pi:image-resize-response", result: await resizeImageInProcess(bytes, "image/png", options) });
assert.deepEqual(await resizeImage(bytes, "image/png", options), await resizeImageInProcess(bytes, "image/png", options));
} finally { await worker.terminate(); }
});
test("missing cwd-relative worker falls back in-process without altering the caller bytes", async () => {
const cwd = process.cwd();
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-worker-fallback-"));
const bytes = png();
const options = { maxWidth: 64, maxHeight: 64 };
const expected = await resizeImageInProcess(bytes, "image/png", options);
try {
process.chdir(dir);
assert.deepEqual(await resizeImage(bytes, "image/png", options), expected);
} finally {
process.chdir(cwd);
await rm(dir, { recursive: true, force: true });
}
});
+331
View File
@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { Agent } from "@earendil-works/pi-agent-core";
import { after, afterEach, test } from "node:test";
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { toLlmMessages, type LhMessage } from "../src/core/native-messages.ts";
import { buildSubagentSteer, type JobKind, type SteerMessage } from "../src/core/contracts.ts";
// Install isolation before importing any storage/configuration composition.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-job-delivery-"));
process.env.LH_DATA_DIR = path.join(directory, "data");
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const { stores } = await import("../src/server/stores.ts");
const { jobTools } = await import("../src/agent/tools/jobs.ts");
const { createJobRunner, researchModelOverride } = await import("../src/agent/subagents/runner.ts");
const { isJobLive } = await import("../src/agent/subagents/aborts.ts");
const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
const {
deliverJobResult, discardChatDeliveries, noteSteersDurable, recoverStaleJobs,
registerLiveAgent, unregisterLiveAgent, settleSteers, setTurnRunner,
} = await import("../src/server/jobs.ts");
const { activityFeed } = await import("../src/server/observability.ts");
const { deleteChatSession } = await import("../src/server/chats.ts");
const chats: string[] = [];
const result = { brief: "Fixture research result", sources: [] };
const question = {
id: "question-message", role: "assistant", content: [{ type: "toolCall", id: "question-call", name: "quiz", arguments: {} }],
api: "openai-completions", provider: "faux", model: "faux", timestamp: 1,
} as unknown as LhMessage;
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function until(predicate: () => boolean): Promise<void> {
const end = Date.now() + 8000;
while (!predicate()) { assert(Date.now() < end, "fixture condition timed out"); await delay(10); }
}
function sweep(): void {
const global = globalThis as { __lhWakePipeline?: { lastSweepAt: number } };
if (global.__lhWakePipeline) global.__lhWakePipeline.lastSweepAt = 0;
recoverStaleJobs();
}
function chat(blocked = false) {
const value = stores.chatStore.createChat("Isolated delivery fixture");
chats.push(value.id);
if (blocked) stores.chatStore.saveMessages(value.id, [question]);
return value;
}
function pending(chatId: string) {
const value = stores.jobStore.createJob({ kind: "research", chatId, toolCallId: "fixture-call" });
const job = stores.jobStore.updateJob(value.id, {
status: "done", result, activity: "finished", delivery: { kind: "result", status: "pending" },
})!;
return { job, steer: buildSubagentSteer(job, result) };
}
function agent() {
const handed: SteerMessage[] = [];
return { handed, value: { steer: (marker: SteerMessage) => handed.push(marker) } as unknown as Parameters<typeof registerLiveAgent>[1] };
}
function persist(chatId: string, steers: readonly LhMessage[]): void {
stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...steers]);
noteSteersDurable(chatId, steers);
}
afterEach(() => {
researchModelOverride.current = undefined;
for (const id of chats.splice(0)) {
unregisterLiveAgent(id); endTurn(id); discardChatDeliveries(id);
stores.jobStore.deleteJobsForChat(id); stores.chatStore.deleteChat(id);
}
setTurnRunner(async () => 500);
});
after(() => fs.rmSync(directory, { recursive: true, force: true }));
for (const [kind, input] of Object.entries({
research: { question: "Saved research question", context: "Saved context" },
demo: { title: "Fixture", brief: "Saved demo brief" },
visual: { brief: "Saved figure brief" },
pdf: { path: "fixture.pdf", question: "Saved PDF question" },
})) test(`${kind}: dispatch saves input before spawn and does not overwrite it on repeated dispatch`, async () => {
const owner = chat();
let spawns = 0;
const tools = jobTools({ chatId: owner.id, jobStore: stores.jobStore, spawn: (job) => {
spawns++;
assert.deepEqual(stores.jobStore.getJob(job.id)!.input, input);
} });
await tools[kind]!.execute!("fixture-dispatch", input);
await tools[kind]!.execute!("fixture-dispatch", input);
assert.equal(spawns, 1);
assert.equal(stores.jobStore.listJobs(owner.id)[0]!.kind, kind as JobKind);
});
test("saved question survives absent transcript; runner ends while delivery is still pending", async () => {
const owner = chat(true);
const input = { question: "The dispatch turn was never saved", context: "Fixture context" };
const job = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "lost-turn", input });
const faux = createFauxCore({});
let seen = "";
faux.setResponses([(context) => { seen = JSON.stringify(context.messages); return fauxAssistantMessage("Fixture brief"); }]);
researchModelOverride.current = { model: faux.getModel(), streamFn: faux.streamSimple };
let delivery: Promise<void> | undefined;
const runner = createJobRunner({ ...stores, deliver: (steer) => { delivery = deliverJobResult(steer); return delivery; } });
await runner.runJob(job.id);
assert(seen.includes(input.question)); assert(seen.includes(input.context));
assert.equal(isJobLive(job.id), false, "worker registration no longer spans delivery");
assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
assert(!activityFeed().jobs.some((item) => item.id === job.id), "no running background-agent row");
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
await delivery;
});
test("missing or blank research question records an honest failure without invoking a model", async () => {
for (const input of [undefined, { question: " " }]) {
const owner = chat();
const job = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "missing-question", input });
const faux = createFauxCore({}); let calls = 0;
faux.setResponses([() => { calls++; return fauxAssistantMessage("must not run"); }]);
researchModelOverride.current = { model: faux.getModel(), streamFn: faux.streamSimple };
const steers: SteerMessage[] = [];
await createJobRunner({ ...stores, deliver: async (steer) => { steers.push(steer); } }).runJob(job.id);
assert.equal(calls, 0);
assert.equal(stores.jobStore.getJob(job.id)!.error, "research question unavailable");
assert.equal(stores.jobStore.getJob(job.id)!.status, "error");
assert.match(steers[0]!.content, /^Research failed: the research question is unavailable\./);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
}
});
test("repeated recovery behind a question yields one handed and one durable marker", async () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
let wakes = 0; setTurnRunner(async () => { wakes++; return 500; });
sweep(); sweep(); sweep();
const first = deliverJobResult(steer);
assert.strictEqual(deliverJobResult(steer), first, "duplicate callers share one logical delivery");
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.deepEqual(next.handed.map((message) => message.id), [steer.id]);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
await first;
assert.equal(wakes, 0);
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered");
});
for (const refused of [409, 403]) test(`duplicate during a ${refused} wake retains one logical owner`, async () => {
const owner = chat(); const { steer } = pending(owner.id);
let release!: () => void; const gate = new Promise<void>((resolve) => { release = resolve; });
let attempts = 0;
setTurnRunner(async (id, input) => {
assert.equal(input.type, "steers"); if (input.type !== "steers") return 500;
assert.equal(input.steers.length, 1);
attempts++;
if (attempts === 1) { await gate; return refused; }
persist(id, input.steers); return 200;
});
const first = deliverJobResult(steer);
await until(() => attempts === 1);
assert.strictEqual(deliverJobResult(steer), first, "wake cargo is still in the registry");
release();
if (refused === 403) {
await delay(30);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
}
await first;
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
});
test("failed start stays quiet across sweeps, then joins a learner run", async () => {
const owner = chat(); const { job, steer } = pending(owner.id);
let attempts = 0; setTurnRunner(async () => { attempts++; return 503; });
const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30);
sweep(); sweep(); await delay(900);
assert.equal(attempts, 1);
assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
assert.equal(stores.jobStore.getJob(job.id)!.error, undefined);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
await delivery;
});
test("HTTP acceptance without a saved marker is not delivery completion or a retry trigger", async () => {
const owner = chat(); const { job, steer } = pending(owner.id);
let attempts = 0;
const accepted = agent();
setTurnRunner(async () => {
attempts++; assert(beginTurn(owner.id));
registerLiveAgent(owner.id, accepted.value, false, new Set([steer.id]));
return 200;
});
const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], true, accepted.value);
sweep(); await delay(900); assert.equal(attempts, 1);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); endTurn(owner.id); await delivery;
});
test("a racing foreground settlement cannot release an outstanding staged wake", async () => {
const owner = chat(); const { steer } = pending(owner.id);
let started = false;
let release!: () => void;
const startup = new Promise<void>((resolve) => { release = resolve; });
let modelCopies = 0;
let nativeCopies = 0;
setTurnRunner(async (id, input) => {
assert.equal(input.type, "steers"); if (input.type !== "steers") return 500;
started = true;
// Subscription wake startup can await catalog refresh before claiming
// the session. Its cargo is not yet a durable input prefix.
await startup;
const faux = createFauxCore({});
faux.setResponses([(context) => {
modelCopies = JSON.stringify(context.messages).split(result.brief).length - 1;
return fauxAssistantMessage("Fixture wake response");
}]);
const wake = new Agent({ initialState: { model: faux.getModel(), messages: input.steers }, convertToLlm: toLlmMessages, streamFn: faux.streamSimple });
assert(beginTurn(id));
registerLiveAgent(id, wake, false, new Set(input.steers.map((message) => message.id)));
await wake.continue();
nativeCopies = wake.state.messages.filter((message) => (message as LhMessage).id === steer.id).length;
persist(id, wake.state.messages.filter((message) => message.role !== "system") as LhMessage[]);
unregisterLiveAgent(id); endTurn(id); settleSteers(id, stores.chatStore.getChat(id)!.messages, false, wake);
return 200;
});
const delivery = deliverJobResult(steer);
await until(() => started);
const foreground = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, foreground.value);
assert.equal(foreground.handed.length, 0, "outstanding wake owns the marker");
unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], false, foreground.value);
release(); await delivery;
assert.equal(modelCopies, 1, "one result in the model's initial context");
assert.equal(nativeCopies, 1, "no self-steering of staged wake cargo");
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
});
test("canonical save wins even if delivery bookkeeping fails; no marker replay", async () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
const delivery = deliverJobResult(steer);
const update = stores.jobStore.updateJob;
stores.jobStore.updateJob = (id, patch) => {
if (id === job.id && patch.delivery?.status === "delivered") throw new Error("fixture acknowledgement write failure");
return update(id, patch);
};
try { persist(owner.id, [steer]); await delivery; }
finally { stores.jobStore.updateJob = update; }
let wakes = 0; setTurnRunner(async () => { wakes++; return 500; });
sweep();
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered");
assert.equal(wakes, 0);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 0); unregisterLiveAgent(owner.id); endTurn(owner.id);
});
test("old running results become finished pending work; old terminal records are untouched", () => {
const owner = chat(true);
const old = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-pending" });
stores.jobStore.updateJob(old.id, { result });
const terminal = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-terminal" });
stores.jobStore.updateJob(terminal.id, { status: "done", result });
sweep();
assert.equal(stores.jobStore.getJob(old.id)!.status, "done");
assert.equal(stores.jobStore.getJob(old.id)!.delivery?.status, "pending");
assert.equal(stores.jobStore.getJob(terminal.id)!.delivery, undefined);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.deepEqual(next.handed.map((message) => message.id), [`steer_${old.id}`]);
unregisterLiveAgent(owner.id); endTurn(owner.id);
});
test("fresh process restores a pending delivery on registration without a jobs poll", () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
const script = `
const { stores } = await import('./src/server/stores.ts');
const { registerLiveAgent, noteSteersDurable, unregisterLiveAgent } = await import('./src/server/jobs.ts');
const { beginTurn, endTurn } = await import('./src/server/turn-liveness.ts');
const handed = []; beginTurn(${JSON.stringify(owner.id)});
registerLiveAgent(${JSON.stringify(owner.id)}, { steer: marker => handed.push(marker) });
const record = stores.chatStore.getChat(${JSON.stringify(owner.id)});
stores.chatStore.saveMessages(record.id, [...record.messages, ...handed]);
noteSteersDurable(record.id, handed); unregisterLiveAgent(record.id); endTurn(record.id);
console.log(JSON.stringify(handed.map(marker => marker.id)));
`;
const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
cwd: process.cwd(), encoding: "utf8", timeout: 15000,
env: { PATH: process.env.PATH, HOME: path.join(directory, "home"), NODE_ENV: "test", LH_DATA_DIR: process.env.LH_DATA_DIR, LH_AUTH_FILE: process.env.LH_AUTH_FILE },
});
assert.equal(child.status, 0, child.stderr);
assert.deepEqual(JSON.parse(child.stdout.trim()), [steer.id]);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered");
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
});
test("deleting legacy completed work needs no preceding recovery poll", async () => {
const owner = chat(true);
const old = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-delete" });
stores.jobStore.updateJob(old.id, { result });
const outcome = await deleteChatSession(owner.id, "preserve-graph");
assert(outcome.ok);
assert.equal(stores.jobStore.getJob(old.id), null);
assert.equal(stores.chatStore.getChat(owner.id), null);
});
test("deletion still refuses a computing worker or a live turn", async () => {
const owner = chat();
const computing = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "computing-delete" });
const blocked = await deleteChatSession(owner.id, "preserve-graph");
assert(!blocked.ok && blocked.code === "jobs_running");
stores.jobStore.updateJob(computing.id, { status: "done" });
assert(beginTurn(owner.id));
const live = await deleteChatSession(owner.id, "preserve-graph");
assert(!live.ok && live.code === "turn_in_flight");
endTurn(owner.id);
});
test("deleting a session discards its pending deliveries without starting a turn", async () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
let wakes = 0; setTurnRunner(async () => { wakes++; return 500; });
const delivery = deliverJobResult(steer);
const outcome = await deleteChatSession(owner.id, "preserve-graph");
assert(outcome.ok);
await delivery; await delay(450);
assert.equal(stores.jobStore.getJob(job.id), null);
assert.equal(stores.chatStore.getChat(owner.id), null);
assert.equal(wakes, 0);
});
+139 -51
View File
@@ -25,8 +25,9 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, afterEach, describe, it } from "node:test";
import { after, afterEach, before, describe, it } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
import {
accumulateJobUsage,
@@ -51,18 +52,20 @@ import { runningTurn } from "../src/server/turn-runs.ts";
import {
abortJob,
deliverJobResult,
noteSteerVisible,
noteSteersDurable,
discardChatDeliveries,
reconcileUserSteers,
recoverStaleJobs,
registerLiveAgent,
setJobRespawn,
setTurnRunner,
setTurnRunner as setRawTurnRunner,
type TurnRunner,
settleSteers,
steerLiveChat,
unregisterLiveAgent,
} from "../src/server/jobs.ts";
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { isJobLive, registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
import { persistTurn } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
@@ -140,11 +143,13 @@ afterEach(() => {
if (job && job.status === "running") {
jobStore.updateJob(id, { status: "done", activity: "settled (fence)" });
}
if (job?.delivery?.status === "pending") jobStore.updateJob(id, { delivery: { ...job.delivery, status: "delivered" } });
unregisterJobAbort(id);
} catch {
/* already gone */
}
}
for (const chatId of createdChats) discardChatDeliveries(chatId);
// Late wakes from a prior test must be inert: a no-op respawn can't
// increment anyone's counter, a no-op runner can't spawn real work.
setJobRespawn(() => {});
@@ -454,16 +459,25 @@ function fakeDeliver() {
steers,
deliver: async (steer: SteerMessage) => {
steers.push(steer);
const jobId = (steer.details as { jobId: string }).jobId;
const job = jobStore.getJob(jobId);
if (job && job.status === "running") {
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
jobStore.updateJob(jobId, { status: failed ? "error" : "done", activity: failed ? "failed" : "delivered" });
}
const job = jobStore.getJob((steer.details as { jobId: string }).jobId)!;
chatStore.saveMessages(job.chatId, [...chatStore.getChat(job.chatId)!.messages, steer]);
noteSteersDurable(job.chatId, [steer]);
},
};
}
/** Fake wakes model canonical input acceptance, not just HTTP acknowledgement. */
function setTurnRunner(run: TurnRunner): void {
setRawTurnRunner(async (chatId, input) => {
const status = await run(chatId, input);
if (status === 200 && input.type === "steers") {
const record = chatStore.getChat(chatId);
if (record) chatStore.saveMessages(chatId, [...record.messages, ...input.steers.filter((s) => !steerDelivered(record.messages, s.id))]);
}
return status;
});
}
// ─── 3. dispatch: the tool call acks mid-turn and spawns ─────────────────────
const RESEARCH_INPUT = {
@@ -526,7 +540,7 @@ describe("dispatch (async protocol §2)", () => {
const done = jobStore.getJob(job!.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered");
assert.equal(done.activity, "finished");
// The steer carries the brief on its model-facing content; the
// artifact payload rides details for the (UI) renderer.
@@ -603,7 +617,7 @@ describe("research runner (async protocol §3)", () => {
// The job is done, with the activity line from the tool call.
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered", "the fake deliver flips the status like the pipeline");
assert.equal(done.activity, "finished", "delivery does not own the worker status");
const result = done.result as ResearchResult;
assert.equal(result.brief, BRIEF);
assert.ok(result.sources.length > 0, "sources parsed from the brief");
@@ -717,6 +731,27 @@ describe("research runner (async protocol §3)", () => {
describe("pdf runner (sources v0)", () => {
const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
const PDF_INPUT = { path: PDF_FIXTURE, question: "What is the integral of x dx?" };
const relativePath = path.join("Course notes", "sample document.pdf");
let home: string;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
before(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-job-home-"));
const target = path.join(home, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(PDF_FIXTURE, target);
process.env.HOME = home;
process.env.USERPROFILE = home;
});
after(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(home, { recursive: true, force: true });
});
/** A pdf-reader that triages, extracts pages 1-2, then briefs. */
function pdfReaderModel(): PiEngine {
@@ -746,7 +781,7 @@ describe("pdf runner (sources v0)", () => {
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered");
assert.equal(done.activity, "finished");
const result = done.result as PdfResult;
assert.equal(result.path, PDF_FIXTURE);
assert.match(result.brief, /integral rule/);
@@ -759,6 +794,54 @@ describe("pdf runner (sources v0)", () => {
assert.ok(steer.content.includes("integral rule"), "steer text carries the brief");
});
for (const mode of ["dispatch", "recovery"] as const) {
it(`resolves a home-relative PDF from ${mode} input without rewriting history`, async () => {
const chat = chatStore.createChat(`Test: relative PDF ${mode}`);
trackChat(chat.id);
const toolCallId = `call-pdf-relative-${mode}`;
const input = { ...PDF_INPUT, path: relativePath };
const messages = nativeToolMessages("read this pdf", "pdf", toolCallId, input);
const before = structuredClone(messages);
chatStore.saveMessages(chat.id, messages);
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId });
trackJob(job.id);
const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
pdfModelOverride.current = pdfReaderModel();
try {
await runner.runJob(job.id, mode === "dispatch" ? input : undefined);
} finally {
pdfModelOverride.current = undefined;
}
// Worker completion is not the delivery barrier: wait for canonical
// acceptance before asserting the history prefix and appended result.
await waitFor(() => jobStore.getJob(job.id)?.delivery?.status === "delivered", 8000, "canonical PDF delivery acknowledgement");
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.deepEqual(done.delivery, { kind: "result", status: "delivered" });
const result = done.result as PdfResult;
assert.equal(result.path, path.join(home, relativePath));
assert.match(result.brief, /integral rule/);
assert.deepEqual(result.pagesRead, [1, 2]);
assert.deepEqual(result.pagesConsulted, [1, 2]);
assert.equal(delivery.steers.length, 1);
assert.equal(input.path, relativePath, "dispatch input remains relative");
const persisted = chatStore.getChat(chat.id)!.messages;
assert.deepEqual(persisted.slice(0, before.length), before, "original history and tool-call arguments remain as supplied");
const steer = delivery.steers[0]!;
assert.equal(steer.id, `steer_${job.id}`);
assert.equal(steer.role, "custom");
assert.equal(steer.customType, "subagent-steer");
const details = steer.details as { jobId: string; kind: string; toolCallId: string; result: PdfResult };
assert.equal(details.jobId, job.id);
assert.equal(details.kind, "pdf");
assert.equal(details.toolCallId, toolCallId);
assert.deepEqual(details.result, result, "the canonical steer carries the resolved PDF result");
assert.deepEqual(persisted.slice(before.length), [steer], "exactly one captured result is appended after the unchanged history");
});
}
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
const chat = chatStore.createChat("Test: pdf runner failure");
trackChat(chat.id);
@@ -827,7 +910,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` });
trackJob(job.id);
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
jobStore.updateJob(job.id, { result, activity: "delivering result" });
jobStore.updateJob(job.id, { result, status: "done", activity: "finished", delivery: { kind: "result", status: "pending" } });
const steer = buildSubagentSteer(job, result);
return { chatId: chat.id, jobId: job.id, steer };
}
@@ -882,7 +965,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after delivery");
assert.equal(jobStore.getJob(jobId)!.status, "done");
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "delivered");
assert.ok(seen, "the turn runner fired");
assert.equal(seen.chatId, chatId);
assert.equal(seen.steers.length, 1);
@@ -920,7 +1003,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
await delay(1200);
assert.equal(fired, 0, "no wake while the quiz is pending");
assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked");
assert.equal(jobStore.getJob(jobId)!.status, "done", "worker is finished while delivery waits");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
// The learner answers: the real tool result clears the dangling call.
const record = chatStore.getChat(chatId)!;
@@ -984,7 +1068,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
await delay(1200);
assert.equal(fired, 0, "no wake while the session sits dismissal-paused");
assert.equal(jobStore.getJob(jobId)!.status, "running", "the delivery holds (queued, not lost)");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending", "the delivery holds (queued, not lost)");
// The user's next message is the wake — its turn settles, and the
// settlement re-pump lands the held delivery right after it.
@@ -1056,7 +1140,8 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => {
await waitFor(() => handed.length === 1, 8000, "marker handed to the live agent");
await delay(1200);
assert.equal(fired, 0, "no wake while the run is live");
assert.equal(jobStore.getJob(jobId)!.status, "running", "job flips only at delivery");
assert.equal(jobStore.getJob(jobId)!.status, "done", "worker finished before delivery");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
// Settlement: the run persisted the injected marker.
unregisterLiveAgent(chatId);
@@ -1169,20 +1254,15 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => {
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
it("noteSteerVisible flips the job at the visibility seam — before any settle (owner ruling 2026-09-26)", async () => {
// The dock fix: the marker reaching the learner's view (wire
// message_end / write-ahead persist) flips the job NOW — the
// delivering turn may keep teaching for minutes. Idempotent with
// the settle-path finalize.
it("only a durable marker acknowledges delivery, independently of worker completion", () => {
const { chatId, jobId, steer } = setup();
assert.equal(jobStore.getJob(jobId)!.status, "running");
noteSteerVisible(steer);
assert.equal(jobStore.getJob(jobId)!.status, "done", "flipped at the seam, no turn settled");
noteSteerVisible(steer); // the settle-path flip re-runs — no-op
assert.equal(jobStore.getJob(jobId)!.status, "done");
// A probe-handoff (no jobId) is a harmless no-op.
const handoff = buildProbeHandoffSteer(chatId);
noteSteerVisible(handoff);
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, steer]);
noteSteersDurable(chatId, [steer]);
noteSteersDurable(chatId, [steer]);
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "delivered");
assert.equal(jobStore.getJob(jobId)!.activity, "finished");
});
it("the idle wake carries EVERY pending marker in ONE turn", async () => {
@@ -1208,17 +1288,25 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => {
});
it("fails the job (error status) when the wake turn errors unrecoverably", async () => {
const { jobId, steer } = setup();
setTurnRunner(async () => 500);
await deliverJobResult(steer).then(
() => assert.fail("expected delivery to reject"),
() => undefined,
);
const errored = jobStore.getJob(jobId)!;
assert.equal(errored.status, "error");
assert.match(errored.error ?? "", /status 500/);
it("a failed start stays pending without changing the worker outcome", async () => {
const { chatId, jobId, steer } = setup();
let attempts = 0;
setTurnRunner(async () => { attempts++; return 500; });
const delivery = deliverJobResult(steer);
await waitFor(() => attempts === 1, 8000, "failed start attempted");
await delay(1000);
assert.equal(attempts, 1, "no automatic retry loop");
assert.equal(jobStore.getJob(jobId)!.status, "done");
assert.equal(jobStore.getJob(jobId)!.error, undefined);
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
const handed: SteerMessage[] = [];
assert.ok(beginTurn(chatId));
registerLiveAgent(chatId, { steer: (m: SteerMessage) => handed.push(m) } as never);
assert.deepEqual(handed.map((m) => m.id), [steer.id]);
unregisterLiveAgent(chatId);
settleSteers(chatId, [steer]);
endTurn(chatId);
await delivery;
});
});
@@ -1554,14 +1642,14 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
}
});
it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => {
it("a finished worker with a queued result is no longer abortable", async () => {
const chat = chatStore.createChat("Test: abort delivering job");
trackChat(chat.id);
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" });
trackJob(job.id);
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
jobStore.updateJob(job.id, { result, activity: "delivering result" });
jobStore.updateJob(job.id, { result, status: "done", activity: "finished", delivery: { kind: "result", status: "pending" } });
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
assert.ok(beginTurn(chat.id));
@@ -1573,18 +1661,18 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
/* dropped by the abort below */
});
await waitFor(() => jobStore.getJob(job.id)!.activity === "delivering result", 8000, "queued");
assert.equal(jobStore.getJob(job.id)!.delivery?.status, "pending");
const outcome = abortJob(job.id);
assert.ok(outcome.ok);
assert.equal(jobStore.getJob(job.id)!.status, "error");
assert.equal(jobStore.getJob(job.id)!.error, "Aborted by learner");
assert.deepEqual(outcome, { ok: false, reason: "not_running" });
assert.equal(jobStore.getJob(job.id)!.status, "done");
assert.equal(jobStore.getJob(job.id)!.error, undefined);
endTurn(chat.id); // gates open — whatever is left in the queue fires
await waitFor(() => fired.length > 0, 8000, "wake fired");
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
assert.equal((fired[0]!.details as { aborted: boolean }).aborted, true, "the abort steer, not the result");
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
assert.equal(fired.length, 1, "exactly one result delivery");
assert.equal(fired[0]!.customType, "subagent-steer");
assert.equal(jobStore.getJob(job.id)!.status, "done");
});
it("outcome reasons: not_found for unknown, not_running for settled jobs", () => {
+298
View File
@@ -0,0 +1,298 @@
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 { SetupDialog } from "../components/setup-dialog.tsx";
import { AgentSection } from "../components/settings/agent-section.tsx";
import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { useModelSetup, savedSlotsConfigured, selectedModelUnavailable, openModelPicker } from "../components/setup-cta.tsx";
import { StatsBar } from "../components/stats-bar.tsx";
import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } 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, HTMLInputElement: dom.window.HTMLInputElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
});
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const originalFetch = globalThis.fetch;
let root: Root | undefined;
let host: HTMLDivElement | undefined;
let requests: ReturnType<typeof deferred<Response>>[] = [];
let writes: ModelsSettings[] = [];
let snapshot: ReturnType<typeof useModelCatalog>;
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" };
const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] };
function catalog(connected = true): ModelCatalogResponse {
return {
models: connected ? [a, b] : [], defaults: null,
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null },
slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } },
providers: [{ ...provider, keyPresent: connected }],
};
}
function Reader() { snapshot = useModelCatalog(); return null; }
function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return <SetupDialog open onClose={onClose} onDone={onDone} />; }
function installFetch() {
requests = []; writes = [];
globalThis.fetch = async (input, init) => {
const path = String(input);
if (path === "/api/models") { const request = deferred<Response>(); requests.push(request); return request.promise; }
if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); }
if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] });
if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] });
if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" });
throw new Error(`Unexpected request ${path}`);
};
}
async function render(children: React.ReactNode) {
if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); }
await act(async () => root!.render(children));
}
async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); }
function element(testid: string) { const e = document.querySelector<HTMLElement>(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; }
async function click(testid: string) { await act(async () => element(testid).click()); }
function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); }
async function warm(body = catalog()) { await render(<Reader />); await answer(0, body); }
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => {
installFetch(); await render(setup());
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await answer(0, null);
assert.ok(element("setup-catalog-error"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await click("setup-retry"); assert.ok(element("setup-stage-loading"));
const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null };
await answer(1, empty); assert.ok(element("setup-stage-keys"));
});
test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => {
installFetch(); await warm();
await render(<><Reader />{setup()}</>);
assert.equal(requests.length, 2);
assert.ok(element("setup-stage-pickers"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" };
fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] };
await answer(1, fresh);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾");
await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]);
await click("setup-thinking-strong");
await act(async () => snapshot.refresh()); await answer(2, null);
assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false);
await click("setup-save");
assert.deepEqual(writes[0], {
strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" },
cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" },
vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" },
});
});
test("zero providers always means exclusive connection stage; reconnect with all saved slots only closes, even if every selection remains unavailable", async () => {
installFetch(); await warm();
let closed = 0, completed = 0;
await render(<><Reader />{setup(() => { closed++; }, () => { completed++; })}</>); await answer(1, catalog());
await click("setup-trigger-strong");
await act(async () => notifySettingsSaved("keys"));
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null);
await answer(2, catalog(false));
assert.ok(element("setup-stage-keys"));
assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
assert.equal(document.querySelector('[data-testid="setup-add-provider"]'), null);
assert.equal(document.querySelector('[data-testid="setup-save"]'), null);
await act(async () => notifySettingsSaved("keys"));
const other = catalog(false); other.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
await answer(3, other);
assert.equal(closed, 1); assert.equal(completed, 0); assert.deepEqual(writes, []);
assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null, "no intermediate picker frame after reconnect");
});
test("cold saved disconnected pins still enter the provider stage, without any model-selector frame", async () => {
installFetch(); await warm();
await act(async () => notifySettingsSaved("keys"));
const stages: Array<string | undefined> = [];
function Probe() {
const { catalog } = useModelCatalog();
React.useLayoutEffect(() => { stages.push(document.querySelector<HTMLElement>('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]);
return null;
}
await render(<><Reader />{setup()}<Probe /></>);
await answer(1, catalog(false));
assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-keys"]);
});
for (const count of [0, 1, 2]) test(`connecting with ${count} saved slots only requires the missing slots; stale identities/thinking survive`, async () => {
installFetch(); const before = catalog(false);
if (count < 1) before.slots.strong = null;
if (count < 2) before.slots.cheap = null;
before.slots.vision = null;
if (before.slots.strong) before.slots.strong.thinkingLevel = "xhigh";
await warm(before);
let closed = 0; await render(<><Reader />{setup(() => { closed++; })}</>); await answer(1, before);
assert.ok(element("setup-stage-keys"));
const connected = { ...before, models: [{ ...b, provider: "other" }], providers: [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }] };
await act(async () => notifySettingsSaved("keys")); await answer(2, connected);
assert.ok(element("setup-stage-pickers")); assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); assert.equal(closed, 0);
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) continue;
await click(`setup-trigger-${slot}`); await click("model-option-claude-opus-5-5");
}
if (count > 0) assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾");
await click("setup-save");
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) assert.deepEqual(writes[0]?.[slot], before.slots[slot]);
else assert.equal(writes[0]?.[slot]?.model?.provider, "other");
}
});
test("a partial setup's model/thinking edits survive exclusive provider recovery, but reopening discards the abandoned draft", async () => {
installFetch(); const before = catalog(); before.slots.cheap = null; before.slots.vision = null;
await warm(before); await render(<><Reader />{setup()}</>); await answer(1, before);
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
await act(async () => notifySettingsSaved("keys")); await answer(2, { ...before, models: [], providers: [] });
assert.ok(element("setup-stage-keys")); assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
await act(async () => notifySettingsSaved("keys")); await answer(3, before);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
await render(<Reader />); await render(<><Reader />{setup()}</>);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); await answer(4, before);
});
test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => {
installFetch(); await warm();
const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } };
let changed: ModelsSettings | undefined;
await render(<><Reader /><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} /></>);
await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]);
assert.equal(element("slot-model-strong").textContent, "model:free▾");
await answer(1, catalog());
await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false));
assert.equal(element("slot-thinking-strong").textContent, "low▾");
await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id);
});
test("saved completeness follows ordinary repairs and survives temporary connection invalidation", async () => {
installFetch(); let state: ReturnType<typeof useModelSetup>;
function StateReader() { state = useModelSetup(false); return null; }
await render(<StateReader />); await answer(0, catalog());
assert.equal(state!.configured, true);
await act(async () => notifySettingsSaved("keys")); assert.equal(state!.configured, true);
const partial = catalog(false); partial.slots.vision = null;
await answer(1, partial); assert.equal(state!.configured, false); assert.equal(state!.zeroKeys, true);
await act(async () => notifySettingsSaved("models")); await answer(2, catalog(false));
assert.equal(state!.configured, true); assert.equal(state!.zeroKeys, true, "saved is not connected");
});
test("blocked session recovery opens its ordinary picker, never changes Strong or substitutes a model", async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
body.slots.strong = { model: { provider: "other", id: b.id }, thinkingLevel: "medium" };
const globals: ModelsSettings = { strong: body.slots.strong };
const session = { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" };
const requestsOnly = globalThis.fetch; const sessionWrites: unknown[] = [];
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return Response.json({ settings: session });
if (input === "/api/chats/fixture/settings") { const next = JSON.parse(String(init?.body)); sessionWrites.push(next); return Response.json({ settings: next }); }
return requestsOnly(input, init);
};
await warm(body); let selected: string | undefined;
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { selected = id; }} /></>);
assert.equal(selected, `${a.provider}:${a.id}`, "current session does not inherit a different Strong pin");
assert.equal(selectedModelUnavailable(body, selected), true);
assert.deepEqual(sessionWrites, []);
await act(async () => openModelPicker());
assert.ok(element("model-menu")); assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null);
assert.equal(document.querySelector('[data-testid="model-option-vendor_model_free"]'), null);
await click("model-option-claude-opus-5-5");
assert.deepEqual(sessionWrites, [{ model: { provider: "other", id: b.id }, thinkingLevel: "xhigh" }]);
assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong });
});
for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
// Global Strong is unavailable, but this existing session owns a valid
// model. Resolve global settings first while keeping its record pending.
const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } };
let resolveSession!: (response: Response) => void;
let rejectSession!: (reason: Error) => void;
const sessionRead = new Promise<Response>((resolve, reject) => { resolveSession = resolve; rejectSession = reject; });
const requestsOnly = globalThis.fetch;
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return sessionRead;
return requestsOnly(input, init);
};
await warm(body); const identities: Array<string | undefined> = [];
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { identities.push(id); }} /></>);
assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case");
assert.equal(element("stats-model").textContent, "—▾");
assert.equal(identities.at(-1), undefined);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong");
await act(async () => {
if (result === "network-failure") rejectSession(new Error("fixture connection failure"));
else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 }));
else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } }));
});
const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined;
assert.equal(identities.at(-1), expected);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin");
assert.equal(document.querySelector('[data-testid="model-menu"]'), null);
assert.deepEqual(writes, []);
});
test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => {
const body = catalog(false);
assert.equal(savedSlotsConfigured(body.slots), true);
assert.equal(selectedModelUnavailable(body, `${a.provider}:${a.id}`), true);
assert.equal(selectedModelUnavailable(undefined, `${a.provider}:${a.id}`), false);
assert.equal(selectedModelUnavailable(body, undefined), false);
body.models = [b]; body.providers = [{ ...provider, keyPresent: true }];
assert.equal(selectedModelUnavailable(body, `${b.provider}:${b.id}`), false);
body.slots.cheap = null; assert.equal(savedSlotsConfigured(body.slots), false);
});
test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => {
installFetch(); await warm();
await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh());
const fresh = catalog(); fresh.models = [b];
await answer(2, fresh); await answer(1, null);
assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); await answer(3, null);
assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false);
await answer(4, fresh);
});
test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => {
const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }];
assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture");
assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 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);
});
});
+165
View File
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test } from "node:test";
import type { ModelsSettings, ModelSlotName } from "../src/core/settings.ts";
import type { NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts";
// Every app import follows isolation. No installed credentials, ambient keys,
// OAuth, or provider requests: subscription state comes from synthetic IPC.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-models-repair-"));
process.env.LH_DATA_DIR = directory;
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
process.env.LH_IGNORE_ENV_KEYS = "1";
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
process.env.LH_DESKTOP_SERVER = "1";
process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
const { piModels, piEngineForSpec } = await import("../src/agent/models.ts");
const { refreshSubscriptionModels } = await import("../src/agent/subscription-bridge.ts");
const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts");
const { readSettings: readSettingsRecord, writeSettings: writeSettingsRecord } = await import("../src/store/settings.ts");
const { PUT } = await import("../app/api/settings/[section]/route.ts");
const { clampThinkingLevel, normalizeContext } = await import("@earendil-works/pi-ai");
const native = piModels().getModels("github-copilot")[0]!;
const copilot = { provider: "github-copilot-subscription", id: native.id };
const replacement = { provider: "openrouter", id: "z-ai/glm-5.3-flash" };
const slots: ModelSlotName[] = ["strong", "cheap", "vision"];
const initial: ModelsSettings = Object.fromEntries(slots.map((slot) => [slot, { model: copilot, thinkingLevel: "high" }]));
let connected = false, revision = 0, leases = 0;
function snapshot(): NativeSubscriptionSnapshot {
return {
statuses: [{ providerId: "github-copilot", available: true, connected, pending: false, allowBackgroundUse: true, connectionId: connected ? "fixture-connection" : null, epoch: revision, authorityRevision: revision, revision }],
models: { "github-copilot": connected ? [native] : [] },
};
}
Object.defineProperty(process, "send", { configurable: true, value: (message: { id: string; op: string }, callback?: () => void) => {
if (message.op !== "snapshot") { leases++; throw new Error("No credential lease expected"); }
queueMicrotask(() => (process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", id: message.id, op: message.op, ok: true, value: snapshot() }));
callback?.(); return true;
} });
async function connection(value: boolean) { connected = value; revision++; await refreshSubscriptionModels(); }
async function put(body: unknown) {
return PUT(new Request("http://localhost/api/settings/models", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), { params: Promise.resolve({ section: "models" }) });
}
beforeEach(async () => {
await connection(false);
writeSettingsRecord({ version: 1, models: initial, general: { autoUpdate: false }, graph: { rendering: false } });
});
after(() => {
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret]]) {
if (value === undefined) delete process.env[key!]; else process.env[key!] = value;
}
fs.rmSync(directory, { recursive: true, force: true });
});
for (const slot of slots) test(`${slot} can be replaced while the other disconnected slots remain saved`, async () => {
const response = await put({ ...initial, [slot]: { model: replacement, thinkingLevel: "xhigh" } });
assert.equal(response.status, 200, JSON.stringify(await response.clone().json()));
const stored = readSettingsRecord()!;
assert.deepEqual(stored.models?.[slot], { model: replacement, thinkingLevel: "max" });
for (const other of slots.filter((name) => name !== slot)) assert.deepEqual(stored.models?.[other], initial[other]);
assert.deepEqual(stored.general, { autoUpdate: false });
assert.deepEqual(stored.graph, { rendering: false });
assert.deepEqual((await response.json()).settings, stored);
assert(!modelCatalog().models.some((model) => model.provider === copilot.provider), "saving stale pins must not make them available choices");
});
test("all three slots can be repaired successively without reconnecting", async () => {
for (const slot of slots) {
const response = await put({ ...readSettingsRecord()!.models, [slot]: { model: replacement } });
assert.equal(response.status, 200);
}
assert.deepEqual(readSettingsRecord()!.models, Object.fromEntries(slots.map((slot) => [slot, { model: replacement }])));
});
test("thinking preferences on unchanged disconnected models can be changed, removed, and restored", async () => {
for (const thinkingLevel of ["max", undefined, "minimal"] as const) {
const strong = { model: copilot, ...(thinkingLevel ? { thinkingLevel } : {}) };
const response = await put({ ...initial, strong });
assert.equal(response.status, 200);
assert.deepEqual(readSettingsRecord()!.models?.strong, strong);
}
await connection(true);
const engine = piEngineForSpec({ ...copilot, thinkingLevel: "minimal" }, false);
assert.equal(engine.level, clampThinkingLevel(native, "minimal"), "reconnected runtime clamps the saved preference");
assert.equal((await put(readSettingsRecord()!.models)).status, 200);
assert.equal(readSettingsRecord()!.models?.strong?.thinkingLevel, engine.level, "known metadata still clamps on write");
});
test("introduced or replaced unknown models still reject, without writing any section", async () => {
for (const model of [
{ provider: copilot.provider, id: "fixture-not-in-catalog" },
{ provider: "xai-subscription", id: copilot.id },
{ provider: "fixture-unknown-provider", id: copilot.id },
{ provider: replacement.provider, id: "fixture-not-in-catalog" },
]) {
const before = readSettingsRecord();
assert.equal((await put({ ...initial, strong: { model } })).status, 400);
assert.deepEqual(readSettingsRecord(), before);
}
writeSettingsRecord({ version: 1, models: { strong: initial.strong } });
assert.equal((await put({ strong: initial.strong, cheap: initial.strong })).status, 400, "a pin saved in one slot cannot authorize another slot");
assert.equal((await put(initial)).status, 400, "missing slots cannot introduce disconnected models");
});
test("a first models write cannot introduce disconnected selections", async () => {
fs.unlinkSync(path.join(directory, "settings.json"));
assert.equal((await put(initial)).status, 400);
assert.equal(readSettingsRecord(), null);
});
test("a replaced stale selection cannot be reintroduced without availability", async () => {
assert.equal((await put({ ...initial, strong: { model: replacement } })).status, 200);
assert.equal((await put(initial)).status, 400);
assert.deepEqual(readSettingsRecord()!.models?.strong?.model, replacement);
});
test("slot removal still replaces the whole section, including clearing all slots", async () => {
assert.equal((await put({ cheap: initial.cheap })).status, 200);
assert.deepEqual(readSettingsRecord()!.models, { cheap: initial.cheap });
assert.equal((await put({})).status, 200);
assert.deepEqual(readSettingsRecord()!.models, {});
});
test("unchanged model identity does not bypass body shape validation", async () => {
for (const body of [
{ ...initial, strong: { model: copilot, thinkingLevel: 7 } },
{ ...initial, strong: { thinkingLevel: "high" } },
{ ...initial, strong: { model: { provider: copilot.provider } } },
]) assert.equal((await put(body)).status, 400);
assert.deepEqual(readSettingsRecord()!.models, initial);
});
test("standalone catalog validation stays strict without a stored section", () => {
assert.equal(validateModelsSettings(initial).ok, false);
assert.equal(validateModelsSettings({ strong: { model: replacement } }).ok, true);
});
test("retained selections remain unavailable at runtime, with no API-key fallback or credential lease", async () => {
await connection(true);
const engine = piEngineForSpec(copilot, false);
await connection(false);
assert.equal((await put({ ...initial, cheap: { model: replacement } })).status, 200);
assert.throws(() => piEngineForSpec(copilot, false), /catalog has no model/);
let requests = 0;
const before = leases;
const key = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "fixture-must-not-be-used";
try {
const result = await engine.streamFn(engine.model, normalizeContext({ messages: [] }), { fetch: async () => { requests++; throw new Error("Must not dispatch"); } }).result();
assert.equal(result.stopReason, "error");
assert.match(result.errorMessage ?? "", /No API-key fallback was used/);
assert.equal(requests, 0);
assert.equal(leases, before);
} finally {
if (key === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = key;
}
});
+12
View File
@@ -0,0 +1,12 @@
/** Run the same fake-input gate used for the traced standalone dependency graph. */
import assert from "node:assert/strict";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { verifyCompressionRuntime } from "../scripts/smoke-compression-runtime.mjs";
test("Next compression keeps drain listeners bounded through backpressure, payload delivery and disconnect", { timeout: 35_000 }, () => {
const root = fileURLToPath(new URL("../", import.meta.url));
const report = verifyCompressionRuntime(path.resolve(root));
assert.deepEqual(report.cases.map((item: { mode: string }) => item.mode), ["/node", "/web", "/identity", "/no-transform", "/abort"]);
});
+203
View File
@@ -0,0 +1,203 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { SessionModel } from "../components/session-core.ts";
import { newUserMessage } from "../src/core/native-messages.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
describe("new-session opening delivery acceptance", () => {
it("notifies acceptance before the record read or teacher stream completes", async () => {
const record = deferred<Response>();
const accepted = deferred<void>();
const calls: string[] = [];
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
calls.push(String(url));
if (String(url) === "/api/chat") return Response.json({ ok: true });
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
return record.promise;
} });
let completed = false;
const sending = m.send({ type: "text", text: "my syllabus" }, () => accepted.resolve()).then(result => { completed = true; return result; });
await accepted.promise;
assert.equal(completed, false);
assert.equal(m.getState().hasConfirmedConversation, false, "optimistic user text is not canonical proof");
record.resolve(Response.json({ messages: [newUserMessage("my syllabus")] }));
assert.equal(await sending, true);
assert.equal(m.getState().hasConfirmedConversation, true);
assert.equal(calls.filter(url => url === "/api/chat").length, 1);
});
it("a teardown racing the POST still cleans up on acknowledgment without attaching", async () => {
const post = deferred<Response>();
let notified = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
assert.equal(String(url), "/api/chat");
return post.promise;
} });
const sending = m.send({ type: "text", text: "hello" }, () => { notified++; });
m.stop();
post.resolve(Response.json({ ok: true }));
assert.equal(await sending, true);
assert.equal(notified, 1);
});
for (const failure of ["refusal", "409", "lost acknowledgment"] as const) {
it(`${failure} does not notify acceptance or leave a false confirmed conversation`, async () => {
let notified = false;
const m = new SessionModel({ chatId: "c1", initialMessages: [], waitMaxMs: 0, fetch: async () => {
if (failure === "lost acknowledgment") throw new TypeError("connection lost");
return new Response("refused", { status: failure === "409" ? 409 : 503 });
} });
assert.equal(await m.send({ type: "text", text: "keep this" }, () => { notified = true; }), false);
assert.equal(notified, false);
assert.equal(m.getState().hasConfirmedConversation, false);
assert.equal(m.getState().nativeMessages.length, 0, "restore the opening draft, not a stranded optimistic bubble");
assert.equal(m.status, "error");
});
}
for (const failure of ["network", "503"] as const) {
it(`a teardown racing an opening ${failure} failure restores the bubble/error recovery state`, async () => {
const post = deferred<Response>();
let notified = 0;
const envelope = JSON.stringify({ code: "models_not_configured", missing: ["strong"] });
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
assert.equal(String(url), "/api/chat", "failure must not attach or resend");
return post.promise;
} });
const sending = m.send({ type: "text", text: "recover this opening" }, () => { notified++; });
m.stop(); // StrictMode's cleanup detaches the consume, NOT this POST.
if (failure === "network") post.reject(new TypeError("opening connection lost"));
else post.resolve(new Response(envelope, { status: 503 }));
assert.equal(await sending, false);
assert.equal(notified, 0);
assert.equal(m.getState().nativeMessages.length, 0, "no stranded optimistic opening bubble");
assert.equal(m.status, "error");
assert.equal(m.getState().error?.message, failure === "network" ? "opening connection lost" : envelope,
"the raw refusal must reach the existing setup-recovery effect");
});
it(`an older opening ${failure} failure removes only its own bubble while a newer send owns state`, async () => {
const first = deferred<Response>();
const second = deferred<Response>();
let posts = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
assert.equal(String(url), "/api/chat");
return ++posts === 1 ? first.promise : second.promise;
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
const newer = m.send({ type: "text", text: "newer text" });
if (failure === "network") first.reject(new TypeError("older lost acknowledgment"));
else first.resolve(new Response("older refusal", { status: 503 }));
assert.equal(await older, false);
assert.equal(m.status, "submitted", "older failure must not settle the newer send");
assert.equal(m.getState().error, null);
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
second.resolve(new Response("newer failure", { status: 503 }));
assert.equal(await newer, false);
assert.equal(m.getState().error?.message, "newer failure");
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"],
"ordinary text retains its existing optimistic failure presentation");
});
}
it("a newer send remains the owner even after it fails and is stopped", async () => {
const first = deferred<Response>();
let posts = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => {
return ++posts === 1 ? first.promise : new Response("newer failure", { status: 503 });
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
await m.send({ type: "text", text: "newer text" });
m.stop();
first.reject(new TypeError("older failure"));
assert.equal(await older, false);
assert.equal(m.status, "ready");
assert.equal(m.getState().error?.message, "newer failure");
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
});
it("a replacement reattach owns state instead of the older stopped opening POST", async () => {
const post = deferred<Response>();
const record = deferred<Response>();
const saved = newUserMessage("server-owned text");
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
if (String(url) === "/api/chat") return post.promise;
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
return record.promise;
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
const replacement = m.resumeStream();
post.reject(new TypeError("older failure"));
assert.equal(await older, false);
assert.equal(m.status, "submitted");
assert.equal(m.getState().error, null);
record.resolve(Response.json({ messages: [saved] }));
await replacement;
assert.equal(m.status, "ready");
assert.deepEqual(m.getState().nativeMessages, [saved]);
assert.equal(m.getState().error, null);
});
it("a stopped replacement reattach still supersedes the older opening refusal", async () => {
const post = deferred<Response>();
const record = deferred<Response>();
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
return String(url) === "/api/chat" ? post.promise : record.promise;
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
const replacement = m.resumeStream();
m.stop();
post.resolve(new Response("older refusal", { status: 503 }));
assert.equal(await older, false);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
assert.equal(m.getState().nativeMessages.length, 0);
record.resolve(Response.json({ messages: [] }));
await replacement;
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
});
it("a stopped ordinary text failure retains the pre-existing teardown behavior", async () => {
const post = deferred<Response>();
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => post.promise });
const sending = m.send({ type: "text", text: "ordinary text" });
m.stop();
post.reject(new TypeError("connection lost"));
assert.equal(await sending, false);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["ordinary text"]);
});
it("post-acceptance attachment failure remains accepted and does not resend input", async () => {
let posts = 0;
let notified = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); }
throw new TypeError("record read failed");
} });
assert.equal(await m.send({ type: "text", text: "hello" }, () => { notified++; }), true);
assert.equal(notified, 1);
assert.equal(posts, 1);
assert.equal(m.status, "error");
});
it("a busy gate sends nothing, then canonical adoption can confirm an earlier lost acknowledgment", async () => {
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => { throw new Error("must not post"); } });
m.readyRef = false;
assert.equal(await m.send({ type: "text", text: "hello" }, () => assert.fail("must not acknowledge")), false);
m.setMessages([newUserMessage("hello")]);
assert.equal(m.getState().hasConfirmedConversation, true);
});
});
+123
View File
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { clearOpeningPrompt, readOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt } from "../components/opening-prompt.ts";
import { SessionStarter } from "../components/start-session.ts";
const original = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
let data: Map<string, string>;
let storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
function install(value: unknown) {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value });
}
beforeEach(() => {
data = new Map();
storage = {
getItem: key => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, value); },
removeItem: key => { data.delete(key); },
};
install(storage);
});
afterEach(() => {
if (original) Object.defineProperty(globalThis, "sessionStorage", original);
else Reflect.deleteProperty(globalThis, "sessionStorage");
});
const fail = () => { throw new Error("synthetic storage failure"); };
describe("opening message storage", () => {
it("keeps full text off the URL and records attempted delivery before send", () => {
const text = "a syllabus with Unicode 世界 & ? # ".repeat(1500);
assert.equal(stashOpeningPrompt("c1", text), true);
assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: false });
assert.equal(stashOpeningPrompt("c1", text, true), true);
assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: true });
assert.equal(clearOpeningPrompt("c1"), true);
assert.equal(readOpeningPrompt("c1"), undefined);
});
it("treats a legacy raw-text stash conservatively as a possibly attempted send", () => {
data.set("lh.openingPrompt.old", "teach me logic");
assert.deepEqual(readOpeningPromptEntry("old"), { text: "teach me logic", attempted: true });
});
for (const unavailable of [undefined, null]) {
it(`reports unavailable storage (${unavailable}) instead of claiming a safe handover`, () => {
install(unavailable);
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
assert.equal(readOpeningPrompt("c1"), undefined);
assert.equal(clearOpeningPrompt("c1"), false);
});
}
it("catches a throwing storage getter", () => {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, get: fail });
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
assert.equal(readOpeningPrompt("c1"), undefined);
assert.equal(clearOpeningPrompt("c1"), false);
});
for (const method of ["getItem", "setItem", "removeItem"] as const) {
it(`contains ${method} exceptions without crashing a source or chat page`, () => {
storage[method] = fail;
assert.doesNotThrow(() => stashOpeningPrompt("c1", "keep this"));
assert.doesNotThrow(() => readOpeningPrompt("c1"));
assert.doesNotThrow(() => clearOpeningPrompt("c1"));
if (method !== "removeItem") assert.equal(stashOpeningPrompt("c1", "keep this"), false);
});
}
it("does not accept a storage write that failed silently", () => {
storage.setItem = () => {};
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
});
});
describe("new-session create and handover", () => {
function createFetch() {
const bodies: unknown[] = [];
const fetchLike = (async (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Response.json({ id: `chat_${bodies.length}` });
}) as typeof fetch;
return { bodies, fetchLike };
}
it("refuses navigation on quota failure, then reuses the created session for unchanged retry", async () => {
const starter = new SessionStarter();
const { bodies, fetchLike } = createFetch();
const text = "full text ".repeat(4000);
const setItem = storage.setItem;
storage.setItem = fail;
await assert.rejects(starter.start({ title: text }, fetchLike));
assert.equal(bodies.length, 1);
storage.setItem = setItem;
assert.equal(await starter.start({ title: text }, fetchLike), "/chat/chat_1");
assert.equal(bodies.length, 1);
assert.equal(readOpeningPrompt("chat_1"), text);
});
it("reuses creation even when verifying the first storage write failed", async () => {
const starter = new SessionStarter();
const { bodies, fetchLike } = createFetch();
const getItem = storage.getItem;
storage.getItem = fail;
await assert.rejects(starter.start({ title: "hello" }, fetchLike));
storage.getItem = getItem;
assert.equal(await starter.start({ title: "hello" }, fetchLike), "/chat/chat_1");
assert.equal(bodies.length, 1);
});
it("changed text, mode, picks or birth settings create a matching new session", async () => {
const starter = new SessionStarter();
const { bodies, fetchLike } = createFetch();
await starter.start({ title: "original" }, fetchLike, "model-a");
await starter.start({ title: "edited" }, fetchLike, "model-a");
await starter.start({ title: "edited", kind: "practice" }, fetchLike, "model-a");
const picked = { title: "edited", kind: "practice" as const, graphContext: { propositions: [{ id: "p1", name: "P", statement: "S" }] } };
await starter.start(picked, fetchLike, "model-a");
await starter.start(picked, fetchLike, "model-b");
assert.equal(bodies.length, 5);
assert.equal(readOpeningPrompt("chat_1"), undefined);
});
it("does not stash or navigate on a refused creation or invalid ID", async () => {
for (const response of [new Response("unavailable", { status: 503 }), Response.json({}), Response.json({ id: "../bad?prompt=leak" })]) {
const starter = new SessionStarter();
await assert.rejects(starter.start({ title: "hello" }, (async () => response) as typeof fetch));
assert.equal(data.size, 0);
}
});
});
+629
View File
@@ -0,0 +1,629 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { renderToStaticMarkup } from "react-dom/server";
import { PathInput, INSERT_FILE_PATHS_EVENT } from "../components/path-input.tsx";
import {
PATH_CARET_ATTRIBUTE, PATH_SOURCE_ATTRIBUTE, PathInputHistory, adjacentPathRange, atomicPathRange,
insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, pathInputStaticRange, readPathInput,
readPathSelection, renderPathInput, replacePathSelection, restorePathSelection,
} from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
function editor() {
const { document } = parseHTML("<html><body><div id='editor'></div></body></html>");
return document.getElementById("editor")!;
}
test("DOM projection is lossless across whitespace, Unicode and all rooted path forms", () => {
const root = editor();
const paths = ["/a/notes one.pdf", "C:\\a\\notes.pdf", "\\\\host\\share\\sheet.csv", "~/notes`final`.pdf"];
const value = ` 👩🏽‍💻\t中\n${paths.map(quoteFilePath).join("\n\t")}\n \n`;
const hosts = renderPathInput(root, value);
assert.equal(hosts.length, paths.length);
for (const { host } of hosts) {
host.textContent = "abbreviated.pdf";
assert.equal(host.getAttribute("contenteditable"), "false");
}
assert.equal(readPathInput(root), value);
assert.equal(root.dataset.empty, "false");
renderPathInput(root, "");
assert.equal(readPathInput(root), "");
assert.equal(root.dataset.empty, "true");
});
test("logical offsets round-trip across text, adjacent chips, line breaks and reversed selections", () => {
const root = editor();
const value = `🙂A ${quoteFilePath("/one")} ${quoteFilePath("/two")}\nend`;
renderPathInput(root, value);
for (let offset = 0; offset <= value.length; offset++) {
const point = pathInputPoint(root, offset);
const mapped = pathInputOffset(root, point.node, point.offset)!;
const token = Array.from(root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`)).find((host) => {
const start = value.indexOf(host.getAttribute(PATH_SOURCE_ATTRIBUTE)!);
return offset > start && offset < start + host.getAttribute(PATH_SOURCE_ATTRIBUTE)!.length;
});
if (!token) assert.equal(mapped, offset, `offset ${offset}`);
else {
const raw = token.getAttribute(PATH_SOURCE_ATTRIBUTE)!;
const start = value.indexOf(raw);
assert.ok(mapped === start || mapped === start + raw.length);
}
}
assert.equal(pathInputOffset(root, root.ownerDocument.body, 0), null);
});
test("BR/block fallback never reads visual labels or normalizes ordinary spaces", () => {
const root = editor();
root.innerHTML = "<div> a </div><div><br></div><div>b</div>";
assert.equal(readPathInput(root), " a \n\nb");
root.innerHTML = "<br>";
assert.equal(readPathInput(root), "");
root.innerHTML = "a<br>b";
assert.equal(readPathInput(root), "a\nb");
});
test("trailing newline caret placeholder is presentation-only and logical end maps before it", () => {
const root = editor();
for (const value of ["\n", "\n\n", "one\n", "one\n\n", "one\n\n\n"]) {
renderPathInput(root, value);
assert.equal(root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
assert.equal(root.lastElementChild?.tagName, "BR");
assert.equal(root.lastElementChild?.getAttribute("aria-hidden"), "true");
assert.equal(root.textContent, value);
assert.equal(readPathInput(root), value);
const point = pathInputPoint(root, value.length);
assert.equal(point.node, root);
assert.equal(point.offset, root.childNodes.length - 1);
assert.equal(pathInputOffset(root, point.node, point.offset), value.length);
assert.equal(pathInputOffset(root, root.lastChild!, 0), value.length);
}
for (const value of ["", "one", "one\ntwo"]) {
renderPathInput(root, value);
assert.equal(root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
assert.equal(readPathInput(root), value);
}
});
test("range replacement/deletion is atomic and leaves neighboring whitespace untouched", () => {
const token = quoteFilePath("/course/notes.pdf");
const value = `a ${token} b`;
assert.deepEqual(atomicPathRange(value, { anchor: 5, focus: 1 }), { start: 1, end: 2 + token.length });
assert.equal(replacePathSelection(value, { anchor: 3, focus: 8 }, "").value, "a b");
assert.deepEqual(adjacentPathRange(value, { anchor: 2, focus: 2 }, "forward"), { anchor: 2, focus: 2 + token.length });
assert.deepEqual(adjacentPathRange(value, { anchor: 2 + token.length, focus: 2 + token.length }, "backward"), { anchor: 2, focus: 2 + token.length });
assert.equal(adjacentPathRange(value, { anchor: 1, focus: 1 }, "forward"), null);
});
test("file insertion uses full quoted sources at saved ranges with separating spaces", () => {
const next = insertFilePaths("readthis", { anchor: 4, focus: 4 }, ["/a b.pdf", "C:\\a\\x.txt"]);
assert.equal(next.value, "read `/a b.pdf` `C:\\a\\x.txt` this");
assert.equal(next.selection.anchor, next.value.length - 4);
assert.equal(insertFilePaths("a\n", { anchor: 2, focus: 2 }, ["~/x"]).value, "a\n`~/x`");
assert.throws(() => insertFilePaths("", { anchor: 0, focus: 0 }, ["relative.txt"]), TypeError);
});
test("owned history restores values and directional selections; a new edit discards redo", () => {
const history = new PathInputHistory();
const a = { value: "a", selection: { anchor: 1, focus: 0 } };
const b = { value: "ab", selection: { anchor: 2, focus: 2 } };
history.push(a);
assert.deepEqual(history.undo(b), a);
assert.deepEqual(history.redo(a), b);
history.undo(b);
history.push(a);
assert.equal(history.redo(b), null);
history.reset();
assert.equal(history.undo(b), null);
});
test("textbox exposes accessibility, placeholder, global-drop markers and requested sizing", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Ask" aria-label="Message" defaultDropTarget className="min-h-24 max-h-64" />);
for (const pattern of [/role="textbox"/, /aria-multiline="true"/, /aria-label="Message"/, /data-file-path-input=""/, /data-file-path-default=""/, /data-placeholder="Ask"/, /min-h-24 max-h-64/]) assert.match(html, pattern);
});
test("SSR preserves the full escaped initial draft without waiting for client effects", () => {
const draft = " Restored <reasoning> & notes\n`/course/full notes.pdf`\n ";
const html = renderToStaticMarkup(<PathInput value={draft} onValueChange={() => assert.fail("SSR cannot emit changes")} placeholder="Reasoning" />);
const { document } = parseHTML(`<html><body>${html}</body></html>`);
const field = document.querySelector('[data-file-path-input]')!;
assert.equal(field.textContent, draft);
assert.equal(field.getAttribute("contenteditable") ?? field.getAttribute("contentEditable"), "true");
assert.equal(field.getAttribute("data-empty"), "false");
assert.equal(field.querySelector("reasoning"), null);
assert.match(html, /&lt;reasoning&gt;/);
const single = renderToStaticMarkup(<PathInput singleLine value={"one\r\ntwo"} onValueChange={() => {}} />);
assert.equal(parseHTML(`<html><body>${single}</body></html>`).document.body.textContent, "onetwo");
});
test("textbox accessible name falls back to placeholder and preserves explicit aria-label", () => {
const fallback = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" />);
assert.match(fallback, /aria-label="Your reasoning"/);
const explicit = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" aria-label="Custom answer" />);
assert.match(explicit, /aria-label="Custom answer"/);
assert.doesNotMatch(explicit, /aria-label="Your reasoning"/);
});
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** linkedom has no Selection/default editing actions. The shim tests our offset
* contract, NOT browser caret navigation or the native IME implementation. */
async function fixture(initial: string, singleLine = false) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div><button id='elsewhere'></button></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
const selection = {
anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0,
setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) {
Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset });
},
};
Object.assign(doc, { getSelection: () => selection, oninput: null });
win.HTMLElement.prototype.focus = function () { active = this; };
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({ React, window: win, document: doc, HTMLElement: win.HTMLElement, Element: win.Element, Event: win.Event, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import("react-dom/client");
const reactRoot = createRoot(doc.getElementById("root")!);
const ref = React.createRef<HTMLDivElement>();
let value = initial;
const writes: string[] = [];
let keys = 0;
function draw() {
reactRoot.render(<PathInput ref={ref} singleLine={singleLine} value={value} onValueChange={(next) => { writes.push(next); value = next; draw(); }} onKeyDown={() => { keys++; }} />);
}
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { reactRoot.unmount(); });
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const root = ref.current!;
root.focus();
function select(anchor: number, focus = anchor) {
restorePathSelection(root, { anchor, focus });
doc.dispatchEvent(new win.Event("selectionchange"));
}
async function event(type: string, properties: Record<string, unknown> = {}) {
const event = new win.Event(type, { bubbles: true, cancelable: true });
Object.assign(event, properties);
await act(async () => { root.dispatchEvent(event); });
return event;
}
return {
root, doc, select, event, writes, selection,
get value() { return value; }, get keys() { return keys; },
async external(next: string) { await act(async () => { value = next; draw(); }); },
async nativeText(next: string, inputType = "insertText") {
await event("beforeinput", { inputType, isComposing: false });
root.replaceChildren(doc.createTextNode(next));
select(next.length);
await event("input", { inputType });
},
};
}
test("native typing, token insertion/deletion and undo/redo share one history", async () => {
const f = await fixture("a");
f.select(1);
await f.nativeText("ab");
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes.pdf"] });
assert.equal(f.value, "ab `/notes.pdf`");
assert.match(f.root.textContent!, /notes.pdf/);
await f.event("keydown", { key: "Backspace" });
assert.equal(f.value, "ab ");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab `/notes.pdf`");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "a");
await f.event("beforeinput", { inputType: "historyRedo" });
assert.equal(f.value, "ab");
});
test("Cmd+Backspace delegates line extent to beforeinput instead of deleting just the final chip", async () => {
const source = "Explain `/tmp/notes.pdf`";
const f = await fixture(source);
f.select(source.length);
const key = await f.event("keydown", { key: "Backspace", metaKey: true });
assert.equal(key.defaultPrevented, false);
assert.equal(f.value, source);
const deletion = await f.event("beforeinput", {
inputType: "deleteSoftLineBackward",
getTargetRanges: () => [{ startContainer: f.root.firstChild!, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }],
});
assert.equal(deletion.defaultPrevented, true);
assert.equal(f.value, "");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, source);
assert.deepEqual(readPathSelection(f.root), { anchor: source.length, focus: source.length });
});
test("native word and soft/hard line deletion ranges retain mixed text/chip extents in both directions", async () => {
const source = "keep old `/tmp/notes.pdf` tail\nnext";
const f = await fixture(source);
const lineEnd = source.indexOf("\n");
const chipEnd = source.indexOf(" tail");
const cases = [
["deleteWordBackward", 5, lineEnd], ["deleteWordForward", 5, chipEnd],
["deleteSoftLineBackward", 0, lineEnd], ["deleteSoftLineForward", 5, lineEnd],
["deleteHardLineBackward", 0, chipEnd], ["deleteHardLineForward", 5, lineEnd],
] as const;
for (const [inputType, start, end] of cases) {
await f.external(source);
const backward = inputType.endsWith("Backward");
f.select(backward ? end : start);
const key = await f.event("keydown", { key: backward ? "Backspace" : "Delete", ctrlKey: true });
assert.equal(key.defaultPrevented, false, inputType);
assert.equal(f.value, source);
const a = pathInputPoint(f.root, start);
const b = pathInputPoint(f.root, end);
const deletion = await f.event("beforeinput", {
inputType,
getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }],
});
assert.equal(deletion.defaultPrevented, true, inputType);
assert.equal(f.value, source.slice(0, start) + source.slice(end), inputType);
assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start });
}
});
test("deleteEntireSoftLine honors the browser extent and never substitutes a chip when ranges are absent", async () => {
const source = "before\nExplain `/tmp/notes.pdf` tail\nafter";
const f = await fixture(source);
const start = source.indexOf("Explain");
const end = source.indexOf("\nafter");
f.select(source.indexOf("`")); // Adjacent to a chip, but not a chip-only command.
for (const getTargetRanges of [undefined, () => []]) {
const missing = await f.event("beforeinput", { inputType: "deleteEntireSoftLine", getTargetRanges });
assert.equal(missing.defaultPrevented, false);
assert.equal(f.value, source);
}
const a = pathInputPoint(f.root, start);
const b = pathInputPoint(f.root, end);
const deletion = await f.event("beforeinput", {
inputType: "deleteEntireSoftLine",
getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }],
});
assert.equal(deletion.defaultPrevented, true);
assert.equal(f.value, "before\n\nafter");
assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start });
});
test("browser target ranges that intersect a visual chip interior expand the complete source atomically", async () => {
const source = "keep old `/tmp/notes.pdf` tail";
const f = await fixture(source);
f.select(5);
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
const start = pathInputPoint(f.root, 5);
const target = { startContainer: start.node, startOffset: start.offset, endContainer: label, endOffset: 2 };
const mapped = pathInputStaticRange(f.root, target)!;
assert.ok(mapped.focus > source.indexOf("`"));
assert.ok(mapped.focus < source.lastIndexOf("`") + 1);
await f.event("beforeinput", { inputType: "deleteWordForward", getTargetRanges: () => [target] });
assert.equal(f.value, "keep tail");
assert.equal(f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}]`), null);
});
test("missing or invalid word/line target ranges defer to native editing and preserve owned undo", async () => {
const source = "Explain `/tmp/notes.pdf`";
const f = await fixture(source);
f.select(source.length);
for (const modifier of ["metaKey", "ctrlKey", "altKey"]) {
for (const key of ["Backspace", "Delete"]) {
const event = await f.event("keydown", { key, [modifier]: true });
assert.equal(event.defaultPrevented, false);
assert.equal(f.value, source);
}
}
for (const inputType of ["deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward", "deleteSoftLineForward", "deleteHardLineBackward", "deleteHardLineForward"]) {
for (const getTargetRanges of [undefined, () => []]) {
const beforeInput = await f.event("beforeinput", { inputType, getTargetRanges });
assert.equal(beforeInput.defaultPrevented, false, `${inputType} must not fall through to adjacent-chip deletion`);
assert.equal(f.value, source);
}
}
const valid = { startContainer: f.root, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length };
for (const getTargetRanges of [
undefined, () => [], () => [{ ...valid, startContainer: f.doc.body }],
() => [{ ...valid, startOffset: -1 }], () => [{ ...valid, endOffset: 999 }],
() => [{ ...valid, endOffset: 0 }], () => [valid, valid], () => { throw new Error("not available"); },
]) {
const beforeInput = await f.event("beforeinput", { inputType: "deleteWordBackward", getTargetRanges });
assert.equal(beforeInput.defaultPrevented, false);
assert.equal(f.value, source);
}
// With no usable target ranges the browser edits the DOM, then input projects it.
await f.nativeText("", "deleteHardLineBackward");
assert.equal(f.value, "");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, source);
});
test("ordinary backward/forward chip deletion and selected ranges retain their existing behavior", async () => {
const source = "a `/tmp/notes.pdf` b";
const f = await fixture(source);
f.select(source.length - 2);
const backspace = await f.event("keydown", { key: "Backspace" });
assert.equal(backspace.defaultPrevented, true);
assert.equal(f.value, "a b");
await f.external(source);
f.select(2);
const forward = await f.event("keydown", { key: "Delete" });
assert.equal(forward.defaultPrevented, true);
assert.equal(f.value, "a b");
await f.external(source);
f.select(source.length, 0);
const selected = await f.event("keydown", { key: "Backspace" });
assert.equal(selected.defaultPrevented, true);
assert.equal(f.value, "");
});
test("immutable SSR fallback never stomps native edits, controlled updates, chips or reset", async () => {
const initial = "Restored draft `/course/original.pdf`";
const f = await fixture(initial);
assert.equal(readPathInput(f.root), initial);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
f.select(initial.length);
await f.nativeText("New typed draft");
assert.equal(readPathInput(f.root), "New typed draft");
assert.equal(f.root.textContent, "New typed draft");
await f.external("Controlled `/course/replaced.pdf`");
assert.equal(readPathInput(f.root), "Controlled `/course/replaced.pdf`");
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
assert.doesNotMatch(f.root.textContent!, /Restored draft/);
await f.external("");
await f.external(""); // Re-render again with the same initial fallback child prop.
assert.equal(readPathInput(f.root), "");
assert.equal(f.root.textContent, "");
assert.equal(f.root.dataset.empty, "true");
});
test("external dictation append/reset and drop at an unfocused saved selection", async () => {
const f = await fixture("one two");
f.select(3);
f.doc.getElementById("elsewhere")!.focus();
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes one.pdf"] });
assert.equal(f.value, "one `/notes one.pdf` two");
assert.equal(f.doc.activeElement, f.root);
assert.equal(f.keys, 0, "staging must not submit");
f.select(f.value.length);
await f.external(`${f.value}\nspoken words`);
assert.equal(readPathInput(f.root), f.value);
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "one `/notes one.pdf` two");
await f.external("");
assert.equal(readPathInput(f.root), "");
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "");
});
test("plaintext paste ignores HTML and copy/cut use full token source in either direction", async () => {
const source = "read `/deep/course/notes.pdf` now";
const f = await fixture(source);
f.select(source.length - 4, 5);
const clipboard = new Map<string, string>();
const clipboardData = { setData: (type: string, text: string) => clipboard.set(type, text), getData: (type: string) => type === "text/plain" ? "<img src=x onerror=alert(1)>\n中" : "<b>unsafe HTML</b>" };
await f.event("copy", { clipboardData });
assert.equal(clipboard.get("text/plain"), "`/deep/course/notes.pdf`");
await f.event("cut", { clipboardData });
assert.equal(f.value, "read now");
f.select(5);
await f.event("paste", { clipboardData });
assert.equal(f.value, "read <img src=x onerror=alert(1)>\n中 now");
assert.equal(f.root.querySelector("img"), null);
assert.equal(readPathInput(f.root), f.value);
});
test("image/file-only paste prevents native insertion without deleting selected text or chips", async () => {
const source = "plain `/course/notes.pdf` end";
const f = await fixture(source);
const clipboardData = { types: ["Files", "image/png"], getData: () => "" };
for (const [anchor, focus] of [[0, 5], [6, source.length - 4]]) {
f.select(anchor!, focus!);
const paste = await f.event("paste", { clipboardData });
assert.equal(paste.defaultPrevented, true);
assert.equal(f.value, source);
assert.equal(readPathInput(f.root), source);
}
assert.deepEqual(f.writes, []);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
});
test("empty clipboard text stays a no-op before, during and after IME with a chip selected", async () => {
const source = "a `/course/notes.pdf` z";
const f = await fixture(source);
f.select(2, source.length - 2);
const clipboardData = { getData: () => "", types: ["text/html"] };
await f.event("paste", { clipboardData });
await f.event("compositionstart");
const during = await f.event("paste", { clipboardData });
assert.equal(during.defaultPrevented, true);
assert.equal(readPathInput(f.root), source);
await f.event("compositionend");
assert.equal(readPathInput(f.root), source, "no empty pending paste can remove the selected chip");
await f.event("paste", { clipboardData });
assert.equal(f.value, source);
assert.deepEqual(f.writes, []);
});
test("Shift+Delete delegates to cut, preserving full-source clipboard and deleting only once", async () => {
const f = await fixture("plain`/next.pdf`");
for (const [source, selected] of [["plain`/next.pdf`", "plain"], ["`/course/notes.pdf` `/next.pdf`", "`/course/notes.pdf` "]] as const) {
await f.external(source);
f.select(0, selected.length);
const key = await f.event("keydown", { key: "Delete", shiftKey: true });
assert.equal(key.defaultPrevented, false, "native Cut shortcut must reach the browser");
assert.equal(f.value, source, "keydown alone must not delete the selection");
let copied = "";
const cut = await f.event("cut", { clipboardData: { setData: (type: string, value: string) => { assert.equal(type, "text/plain"); copied = value; } } });
assert.equal(cut.defaultPrevented, true);
assert.equal(copied, selected);
assert.equal(f.value, "`/next.pdf`");
const beforeInput = await f.event("beforeinput", { inputType: "deleteByCut" });
assert.equal(beforeInput.defaultPrevented, true);
assert.equal(f.value, "`/next.pdf`", "a trailing deleteByCut must not delete the neighboring chip");
}
});
test("copying even a partial visual chip label copies its complete source", async () => {
const f = await fixture("read `/deep/course/notes.pdf` now");
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
f.selection.setBaseAndExtent(label, 1, label, 3);
f.doc.dispatchEvent(new window.Event("selectionchange"));
let copied = "";
await f.event("copy", { clipboardData: { setData: (_type: string, value: string) => { copied = value; } } });
assert.equal(copied, "`/deep/course/notes.pdf`");
});
test("composition keeps native DOM untouched, emits once, and suppresses consumer Enter", async () => {
const f = await fixture("hello ");
f.select(6);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("hello 中"));
f.select(7);
await f.event("input", { isComposing: true });
await f.event("keydown", { key: "Enter", keyCode: 229, isComposing: true });
assert.equal(f.writes.length, 0);
assert.equal(f.keys, 0);
assert.equal(readPathInput(f.root), "hello 中");
await f.event("compositionend");
assert.deepEqual(f.writes, ["hello 中"]);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "hello ");
});
test("Enter inserts a literal newline and range deletion spans text and complete chips", async () => {
const f = await fixture("a `/one.pdf` b");
f.select(f.value.length);
await f.event("beforeinput", { inputType: "insertParagraph" });
assert.equal(f.value, "a `/one.pdf` b\n");
f.select(1, f.value.length - 1);
await f.event("keydown", { key: "Delete" });
assert.equal(f.value, "a\n");
assert.equal(readPathInput(f.root), "a\n");
});
test("multiple trailing newlines preserve selection and survive native Backspace plus undo/redo", async () => {
const f = await fixture("one\n\n");
f.select(f.value.length, 1);
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 1 });
f.select(f.value.length);
assert.equal(f.selection.focusNode, f.root);
assert.equal(f.root.lastElementChild?.hasAttribute(PATH_CARET_ATTRIBUTE), true);
// linkedom has no default Backspace action; model the native text deletion.
await f.nativeText("one\n", "deleteContentBackward");
assert.equal(f.value, "one\n");
assert.equal(readPathInput(f.root), f.value);
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "one\n\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 5 });
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true, shiftKey: true });
assert.equal(f.value, "one\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 4, focus: 4 });
await f.external("");
assert.equal(f.root.dataset.empty, "true");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("single-line mode never adds a trailing newline caret placeholder", async () => {
const f = await fixture("one\n\n", true);
assert.equal(readPathInput(f.root), "one");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("paste during IME stays plaintext and is deferred until composition finishes", async () => {
const f = await fixture("");
f.select(0);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("中"));
f.select(1);
await f.event("input", { isComposing: true });
assert.equal(f.root.dataset.empty, "false", "placeholder hides without normalizing the composing DOM");
const paste = await f.event("paste", { clipboardData: { getData: () => "<b>plain</b>" } });
assert.equal(paste.defaultPrevented, true);
assert.equal(readPathInput(f.root), "中");
assert.equal(f.writes.length, 0);
await f.event("compositionend");
assert.equal(f.value, "中<b>plain</b>");
assert.equal(f.root.querySelector("b"), null);
});
test("single-line normalization strips only CR/LF and remaps directional UTF-16 selections", () => {
const source = "🙂\r\n中\t x\ny\r";
assert.equal(normalizePathInputValue(source, false), source);
assert.equal(normalizePathInputValue(source, true), "🙂中\t xy");
assert.deepEqual(normalizePathEdit({ value: source, selection: { anchor: source.length, focus: 4 } }, true), {
value: "🙂中\t xy", selection: { anchor: 7, focus: 2 },
});
});
test("single-line mode exposes native input accessibility and horizontal, nonwrapping layout", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} singleLine className="h-8" />);
assert.match(html, /aria-multiline="false"/);
assert.match(html, /whitespace-pre /);
assert.match(html, /overflow-x-auto/);
assert.match(html, /overflow-y-hidden/);
assert.doesNotMatch(html, /whitespace-pre-wrap/);
assert.match(html, /h-8/);
});
test("single-line initial/external assignment sanitizes silently; native edits and paste emit sanitized values", async () => {
const f = await fixture("one\r\ntwo\n", true);
assert.equal(readPathInput(f.root), "onetwo");
assert.deepEqual(f.writes, []);
await f.external("hello\r\nworld");
assert.equal(readPathInput(f.root), "helloworld");
assert.deepEqual(f.writes, []);
f.select(10);
await f.nativeText("ab\r\nc");
assert.equal(f.value, "abc");
assert.equal(readPathInput(f.root), "abc");
assert.equal(pathInputOffset(f.root, f.selection.focusNode!, f.selection.focusOffset), 3);
await f.event("paste", { clipboardData: { getData: () => "x\r\n y\n" } });
assert.equal(f.value, "abcx y");
assert.deepEqual(f.writes, ["abc", "abcx y"]);
});
test("single-line Enter reaches its consumer but cannot insert a newline", async () => {
const f = await fixture("answer", true);
f.select(6);
await f.event("keydown", { key: "Enter", shiftKey: true });
assert.equal(f.keys, 1);
const paragraph = await f.event("beforeinput", { inputType: "insertParagraph" });
const lineBreak = await f.event("beforeinput", { inputType: "insertLineBreak" });
assert.equal(paragraph.defaultPrevented, true);
assert.equal(lineBreak.defaultPrevented, true);
assert.equal(f.value, "answer");
assert.deepEqual(f.writes, []);
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/course/notes.pdf"] });
assert.equal(f.value, "answer `/course/notes.pdf`");
});
test("single-line IME sanitizes after completion, and queued external assignments do not synthesize changes", async () => {
const f = await fixture("a", true);
f.select(1);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("a中\r\n文"));
f.select(5);
await f.event("input", { isComposing: true });
assert.deepEqual(f.writes, []);
await f.event("compositionend");
assert.equal(f.value, "a中文");
assert.equal(readPathInput(f.root), "a中文");
await f.event("compositionstart");
await f.external("external\r\nvalue");
assert.equal(readPathInput(f.root), "a中文", "assignment waits for native composition to finish");
await f.event("compositionend");
assert.equal(readPathInput(f.root), "externalvalue");
assert.deepEqual(f.writes, ["a中文"]);
});
+105 -1
View File
@@ -8,9 +8,10 @@
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { realHeadless } from "../src/agent/subagents/headless.ts";
import { MAX_PDF_CANVAS_PIXELS, MAX_PDF_CANVAS_SIDE, pdfCanvasGeometry, realHeadless } from "../src/agent/subagents/headless.ts";
import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts";
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
@@ -31,6 +32,83 @@ function pngFacts(buf: Buffer): { w: number; h: number; idats: number } {
return { w, h, idats };
}
/** Synthetic page only — no personal PDF or external fixture generator. */
function oversizedPdf(): Buffer {
const content = "0 0 1 rg 0 0 20000 10000 re f\n";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20000 10000] /Rotate 90 /Resources << >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`,
];
let pdf = "%PDF-1.7\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(pdf);
}
describe("PDF pre-allocation canvas geometry", () => {
it("uses decimal 16 megapixels and an 8192-pixel side limit", () => {
assert.equal(MAX_PDF_CANVAS_PIXELS, 16_000_000);
assert.equal(MAX_PDF_CANVAS_SIDE, 8192);
});
it("preserves ordinary requested DPI, including fractional ceil dimensions and low DPI", () => {
for (const dpi of [11, 72, 144, 150, 300]) {
const geometry = pdfCanvasGeometry(595.28, 841.89, dpi);
assert.deepEqual(geometry, {
scale: dpi / 72,
width: Math.ceil(595.28 * dpi / 72),
height: Math.ceil(841.89 * dpi / 72),
});
}
});
it("bounds extreme aspect ratios, huge geometry, sides, and rounded pixel area without upscaling", () => {
const cases: readonly (readonly [number, number, number])[] = [
[1e12, 0.000001, 300],
[0.000001, 1e12, 300],
[1e308, 1e308, Number.MAX_VALUE],
[8192.000001, 10, 72],
[6100, 3900, 72], // ceil at the area-limited scale exceeds 16MP
[4000.000001, 4000.000001, 72],
[4000, 4000, 72], // exactly 16MP stays unchanged
];
for (const [width, height, dpi] of cases) {
const result = pdfCanvasGeometry(width, height, dpi);
assert.ok(result.scale > 0 && result.scale <= dpi / 72);
assert.equal(result.width, Math.max(1, Math.ceil(width * result.scale)));
assert.equal(result.height, Math.max(1, Math.ceil(height * result.scale)));
assert.ok(result.width <= MAX_PDF_CANVAS_SIDE && result.height <= MAX_PDF_CANVAS_SIDE);
assert.ok(result.width * result.height <= MAX_PDF_CANVAS_PIXELS);
}
assert.deepEqual(pdfCanvasGeometry(4000, 4000, 72), { scale: 1, width: 4000, height: 4000 });
});
it("uses rotated viewport dimensions symmetrically", () => {
const normal = pdfCanvasGeometry(20000, 10000, 144);
const rotated = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(rotated.scale, normal.scale);
assert.equal(rotated.width, normal.height);
assert.equal(rotated.height, normal.width);
});
it("rejects nonfinite or nonpositive dimensions and DPI cleanly", () => {
for (const invalid of [0, -1, NaN, Infinity, -Infinity]) {
assert.throws(() => pdfCanvasGeometry(invalid, 792, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, invalid, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, 792, invalid), /DPI must be finite and positive/);
}
});
});
describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
it("the shared font directory ends in pdfjs's required slash on every OS", () => {
const fonts = standardFontsDir();
@@ -63,6 +141,32 @@ describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`);
});
it("bounds a synthetic oversized rotated PDF before native canvas allocation", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-canvas-test-"));
try {
const fixture = path.join(dir, "oversized.pdf");
fs.writeFileSync(fixture, oversizedPdf());
const [page] = await realHeadless.renderPdfPages(fixture, [1], { dpi: 144 });
assert.ok(page);
const facts = pngFacts(page.png);
const expected = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(facts.w, expected.width);
assert.equal(facts.h, expected.height);
assert.ok(facts.h > facts.w, "PDF rotation is included before scaling");
assert.ok(facts.w <= 8192 && facts.h <= 8192);
assert.ok(facts.w * facts.h <= 16_000_000);
assert.equal(facts.idats, 1, "raw renderer still returns canonical PNG");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("rejects invalid DPI before opening the PDF", async () => {
for (const dpi of [0, -1, NaN, Infinity, -Infinity, Number.MIN_VALUE]) {
await assert.rejects(realHeadless.renderPdfPages("unused.pdf", [1], { dpi }), /DPI must be finite and positive/);
}
});
it("skips out-of-range page numbers silently", async () => {
const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 });
assert.deepEqual(
+81 -3
View File
@@ -5,8 +5,10 @@
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { after, before, describe, it } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
@@ -28,10 +30,12 @@ const PNG_1PX = Buffer.from([
0x42, 0x60, 0x82,
]);
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } {
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[]; paths: string[] } {
const calls: string[] = [];
const paths: string[] = [];
return {
calls,
paths,
available: () => true,
unavailableReason: () => null,
runDemo: async () => {
@@ -40,7 +44,8 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp
renderSvg: async () => {
throw new Error("unused");
},
renderPdfPages: async (_pdfPath, pages) => {
renderPdfPages: async (pdfPath, pages) => {
paths.push(pdfPath);
calls.push(`render:${pages.join(",")}`);
rendered.pages.push(...pages);
return pages.map((page) => ({ page, png: PNG_1PX }));
@@ -48,6 +53,79 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp
};
}
describe("pdf-reader source paths", () => {
let home: string;
const relativePath = path.join("Course notes", "sample document.pdf");
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
before(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-home-"));
const target = path.join(home, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(FIXTURE, target);
process.env.HOME = home;
process.env.USERPROFILE = home;
});
after(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(home, { recursive: true, force: true });
});
for (const kind of ["absolute", "home-relative"] as const) {
it(`${kind} path reaches text tools and rendering as the same absolute path`, async () => {
const absolutePath = path.join(home, relativePath);
const inputPath = kind === "absolute" ? absolutePath : relativePath;
assert.equal(os.homedir(), home);
assert.notEqual(path.resolve(relativePath), absolutePath, "cwd is not the learner's home");
const headless = fakeHeadless();
const engine = fauxEngine([
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxToolCall("pdf_search", { query: "integral", literal: true }, { id: "s1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "2" }, { id: "e1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "3" }, { id: "r1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Read the requested document (p. 1, p. 2, p. 3).")]),
]);
let lastContext = "";
const stream = engine.streamFn;
engine.streamFn = (...args) => {
lastContext = JSON.stringify(args[1]);
return stream(...args);
};
const result = await runPdfReaderAgent(
{ path: inputPath, question: "Read the document" },
{ engine, headless },
);
assert.equal(result.path, absolutePath);
assert.match(result.brief, /^Read the requested document/);
assert.match(lastContext, /Pages: 3/, "metadata opened the home-anchored PDF");
assert.deepEqual(result.pagesRead, [2, 3]);
assert.deepEqual(result.pagesConsulted, [1, 2, 3], "search and extraction opened the home-anchored PDF");
assert.deepEqual(headless.paths, [absolutePath]);
});
}
it("a missing home-relative source fails before any model call", async () => {
let modelCalls = 0;
const engine = fauxEngine([]);
engine.streamFn = () => {
modelCalls++;
throw new Error("the model must not be called");
};
const missing = path.join("Course notes", "missing.pdf");
const result = await runPdfReaderAgent({ path: missing, question: "anything" }, { engine });
assert.equal(result.path, path.join(home, missing));
assert.match(result.brief, /^PDF failed:.*ENOENT/);
assert.equal(modelCalls, 0);
assert.deepEqual(result.pagesRead, []);
assert.deepEqual(result.pagesConsulted, []);
});
});
describe("pdf-reader loop (faux engine)", () => {
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
const engine = fauxEngine([
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { findPendingToolCall } from "../src/core/pending.ts";
import { danglingQuestionOf, pendingQuestionOf, newAnswerResult, newUserMessage, type LhMessage } from "../src/core/native-messages.ts";
import { inputAppends } from "../src/core/input-events.ts";
import { projectView } from "../src/core/view.ts";
function assistant(id: string, ...calls: string[]): LhMessage {
return {
id, role: "assistant", content: calls.map(id => ({ type: "toolCall", id, name: "quiz", arguments: { question: id } })),
stopReason: "toolUse", timestamp: 1, api: "openai-completions", provider: "fixture", model: "fixture",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
}
function pending(messages: LhMessage[], view = projectView(messages)) {
return findPendingToolCall(view, pendingQuestionOf(messages));
}
test("a later assistant question supersedes an unanswered question in the same projected group", () => {
const messages = [assistant("old", "old-question"), assistant("new", "new-question")];
assert.equal(projectView(messages).length, 1, "the view merges both assistant boundaries");
const question = pending(messages)!;
assert.equal(question.toolCallId, "new-question");
assert.doesNotThrow(() => inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [], dontKnow: true } as never }));
});
test("the newest assistant's parallel questions retain call order and never revive older questions", () => {
const messages = [assistant("old", "old-question"), assistant("new", "first", "second")];
assert.equal(pending(messages)?.toolCallId, "first");
messages.push({ ...newAnswerResult("first", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-first" });
assert.equal(pending(messages)?.toolCallId, "second");
messages.push({ ...newAnswerResult("second", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-second" });
assert.equal(pending(messages), null);
});
test("the authoritative question still waits for projected input validation", () => {
const messages = [assistant("old", "old-question"), assistant("new", "new-question")];
const view = projectView(messages);
for (const message of view) for (const part of message.parts) {
if (part.type === "tool-quiz" && part.toolCallId === "new-question") part.state = "input-streaming";
}
assert.equal(pending(messages, view), null);
});
// Synthetic compatibility cases, not claims about the affected user's history.
test("reused ids select the newest native question's input, not an older available part", () => {
const old = assistant("old", "call_0");
const newer = assistant("new", "call_0");
if (newer.role === "assistant") newer.content.unshift({ type: "text", text: "new teaching" });
if (newer.role === "assistant" && newer.content[1]?.type === "toolCall") newer.content[1].arguments = { question: "NEW" };
const messages = [old, newer];
const question = pending(messages)!;
assert.equal(question.messageId, "new");
assert.equal(question.contentIndex, 1);
assert.deepEqual(question.input, { question: "NEW" });
assert.notEqual(question.key, pending([old])!.key);
assert.deepEqual(danglingQuestionOf(messages), { toolCallId: "call_0", tool: "quiz", input: { question: "NEW" } }, "server authority/shape unchanged");
const answered = inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [] } as never });
const parts = projectView([...messages, ...answered]).flatMap(m => m.parts).filter(p => p.type === "tool-quiz");
assert.equal(parts[0]!.state, "input-available", "old transcript card is untouched");
assert.equal(parts[1]!.state, "output-available", "result still pairs newest-first");
assert.equal(pending([...messages, ...answered]), null);
});
for (const state of ["input-streaming", "output-error", "output-available"]) {
test(`an older available duplicate never bypasses the authoritative part's ${state} state`, () => {
const messages = [assistant("old", "call_0"), assistant("new", "call_0")];
const view = projectView(messages);
const ownPart = view.flatMap(m => m.parts).find(p => p.sourceMessageId === "new" && p.type === "tool-quiz")!;
ownPart.state = state;
assert.equal(pending(messages, view), null);
});
}
test("a missing authoritative part cannot fall back to another occurrence", () => {
const messages = [assistant("old", "call_0"), assistant("new", "call_0")];
assert.equal(pending(messages, projectView(messages.slice(0, 1))), null);
});
test("older questions never revive across display groups or after a text-only newest assistant", () => {
const messages = [assistant("old", "old-q"), newUserMessage("another turn"), assistant("new", "new-q")];
assert.equal(pending(messages)?.toolCallId, "new-q");
assert.equal(pending([...messages, newAnswerResult("new-q", "quiz", {} as never)]), null);
const textOnly = { ...assistant("text"), content: [{ type: "text", text: "done" }] } as LhMessage;
assert.equal(pending([...messages, textOnly]), null);
});
test("unique question keys survive live-to-persisted message stamps without resetting drafts", () => {
const live = assistant("c_live", "unique-q");
const persisted = { ...live, id: "persisted" };
assert.equal(pending([live])!.key, pending([persisted])!.key);
assert.notEqual(pending([live])!.messageId, pending([persisted])!.messageId);
});
test("only temporary successful question placeholders are excluded from client pending authority", () => {
const message = assistant("new", "q");
const placeholder = { ...newAnswerResult("q", "quiz", {} as never), id: "c_placeholder", details: null };
for (const tool of ["quiz", "probe", "ask_user_question", "explain", "recall"]) {
const own = { ...placeholder, toolName: tool };
assert.equal(pendingQuestionOf([message, own])?.toolCallId, "q");
assert.equal(danglingQuestionOf([message, own]), null, "server/persisted policy does not change");
}
for (const result of [
{ ...placeholder, id: "persisted-null-result" },
{ ...placeholder, details: undefined },
{ ...placeholder, isError: true },
{ ...placeholder, isError: true, content: [{ type: "text" as const, text: "Operation aborted" }] },
{ ...placeholder, toolName: "visual" },
{ ...newAnswerResult("q", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "c_answer" },
]) {
assert.equal(pendingQuestionOf([message, result]), null, "real answers, persisted results and failures still resolve the call");
}
});
test("native occurrence checks preserve every question tool and its own live validation gate", () => {
for (const tool of ["quiz", "probe", "ask_user_question", "explain", "recall"]) {
const message = assistant("live", "q");
if (message.role === "assistant" && message.content[0]?.type === "toolCall") message.content[0].name = tool;
const live = { executions: new Map(), inFlight: true, streamingIds: new Set(["live"]) };
assert.equal(pending([message], projectView([message], live)), null);
live.executions.set("q", { isError: false });
assert.equal(pending([message], projectView([message], live))?.tool, tool);
live.executions.set("q", { isError: true });
assert.equal(pending([message], projectView([message], live)), null);
}
});
+132
View File
@@ -0,0 +1,132 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { test } from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { QuestionDock } from "../components/question-dock.tsx";
import { SessionModel } from "../components/session-core.ts";
import { findPendingToolCall } from "../src/core/pending.ts";
import { pendingQuestionOf, type LhMessage } from "../src/core/native-messages.ts";
import type { QuizToolInput } from "../src/core/question-core.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
function question(id: string, callId: string, text: string): LhMessage {
return { id, role: "assistant", content: [{ type: "toolCall", id: callId, name: "quiz", arguments: {
question: text, options: [{ label: "Yes", value: "yes" }, { label: "No", value: "no" }], correctAnswer: "yes", explanation: "Yes is correct.",
} }], stopReason: "toolUse", timestamp: 1 } as unknown as LhMessage;
}
// The same keyed-content boundary as Chat's DockOverlay. This exercises the
// actual form state under synthetic DOM, not CSS layout or a live provider.
for (const reusedId of [false, true]) {
test(`question form resets on a newer question (${reusedId ? "reused" : "unique"} call id)`, async () => {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const render = async (messages: LhMessage[]) => {
const pending = pendingQuestionOf(messages)!;
await act(async () => root.render(<QuestionDock key={`question:${pending.key}`}
toolName="quiz" toolCallId={pending.toolCallId} input={pending.input as QuizToolInput}
expanded onExpand={() => {}} onSubmit={() => {}} dictationClaim={false} />));
};
try {
const old = question("old", "q1", "OLD question");
await render([old]);
const oldForm = host.querySelector('[data-testid="question-dock"]');
await act(async () => { host.querySelector<HTMLButtonElement>('[role="radio"]')!.click(); });
assert.ok(host.querySelector('[role="radio"][aria-checked="true"]'));
await render([old, question("new", reusedId ? "q1" : "q2", "NEW question")]);
assert.notEqual(host.querySelector('[data-testid="question-dock"]'), oldForm);
assert.equal(host.querySelector('[role="radio"][aria-checked="true"]'), null);
assert.match(host.textContent!, /NEW question/);
assert.doesNotMatch(host.textContent!, /OLD question/);
} finally { await act(async () => root.unmount()); host.remove(); }
});
}
test("live question validation → turn_end placeholder → canonical sync keeps the same form and typed reasoning", async () => {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const encoder = new TextEncoder();
let wire!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({ start(controller) { wire = controller; } });
const model = new SessionModel({ chatId: "fixture", initialMessages: [], fetch: async () => new Response(body) });
let submitted: { thinking?: string } | undefined;
const render = () => {
const state = model.getState();
const pending = findPendingToolCall(state.messages, pendingQuestionOf(state.nativeMessages));
root.render(pending ? <QuestionDock key={`question:${pending.key}`}
toolName="quiz" toolCallId={pending.toolCallId} input={pending.input as QuizToolInput}
expanded onExpand={() => {}} onSubmit={answer => { submitted = answer; }} dictationClaim={false} /> : null);
};
model.onChange = render;
const event = async (value: unknown) => {
await act(async () => { wire.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`)); });
};
let consume: Promise<void> | undefined;
try {
await act(async () => { consume = model.resumeStream(false); });
const saved = question("saved", "q1", "Streamed question");
const { id: _savedId, ...message } = saved;
await event({ type: "message_start", message });
await event({ type: "message_end", message });
assert.equal(host.querySelector('[data-testid="question-dock"]'), null, "validation still gates the form");
await event({ type: "tool_execution_end", toolCallId: "q1", toolName: "quiz", isError: false,
result: { content: [{ type: "text", text: "Question posed" }], details: null } });
const form = host.querySelector('[data-testid="question-dock"]');
assert.ok(form);
const occurrence = pendingQuestionOf(model.getState().nativeMessages)!;
await act(async () => {
host.querySelector<HTMLButtonElement>('[role="radio"]')!.click();
const editor = host.querySelector<HTMLElement>('[data-testid="thinking-field"] [data-file-path-input]')!;
editor.textContent = "Keep my typed reasoning";
editor.dispatchEvent(new window.Event("input", { bubbles: true }));
});
await event({ type: "turn_end", message, toolResults: [{ role: "toolResult", toolCallId: "q1", toolName: "quiz",
content: [{ type: "text", text: "Question posed" }], details: null, isError: false, timestamp: 1 }] });
assert.ok(host.querySelector('[data-testid="question-dock"]') === form, "placeholder must never unmount the form");
assert.deepEqual(pendingQuestionOf(model.getState().nativeMessages), occurrence, "exact native occurrence stays pending");
await act(async () => { model.setMessages([saved]); });
assert.ok(host.querySelector('[data-testid="question-dock"]') === form, "canonical save must not reset the form either");
assert.ok(host.querySelector('[role="radio"][aria-checked="true"]'));
assert.equal(host.querySelector('[data-testid="thinking-field"] [data-file-path-input]')!.textContent, "Keep my typed reasoning");
await act(async () => { host.querySelector('form')!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); });
assert.equal(submitted?.thinking, "Keep my typed reasoning", "the React form state, not only DOM text, survived");
} finally {
await act(async () => {
wire.enqueue(encoder.encode("data: [DONE]\n\n")); wire.close();
await consume;
root.unmount();
});
model.onChange = undefined;
host.remove();
}
});
test("saving a unique live question preserves its mounted form and selection", async () => {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const live = question("c_live", "unique-q", "Same question");
const saved = { ...live, id: "saved" };
const render = async (messages: LhMessage[]) => {
const pending = pendingQuestionOf(messages)!;
await act(async () => root.render(<QuestionDock key={`question:${pending.key}`}
toolName="quiz" toolCallId={pending.toolCallId} input={pending.input as QuizToolInput}
expanded onExpand={() => {}} onSubmit={() => {}} dictationClaim={false} />));
};
try {
await render([live]);
const form = host.querySelector('[data-testid="question-dock"]');
await act(async () => { host.querySelector<HTMLButtonElement>('[role="radio"]')!.click(); });
// Append-only adoption can briefly retain the live copy beside the saved one.
await render([saved, live]);
await render([saved]);
assert.equal(host.querySelector('[data-testid="question-dock"]'), form);
assert.ok(host.querySelector('[role="radio"][aria-checked="true"]'));
} finally { await act(async () => root.unmount()); host.remove(); }
});
+26 -7
View File
@@ -103,6 +103,16 @@ test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keep
assert.equal(still, "probe");
});
test("phaseAt3 and phaseTimeline: explicit probe refusals never advance the UI phase", () => {
const refused = assistantTool("probe_done", "output-available", { ok: false, error: "question open" });
const errored = assistantTool("probe_done", "output-error", { ok: true });
const tagged = assistantQuiz({ nodeId: "n1" });
const clean = assistantTool("probe_done", "output-available", { ok: true });
const messages = [refused, errored, tagged, clean, tagged];
assert.equal(phaseAt3(messages, 2), "probe");
assert.deepEqual(phaseTimeline(messages), ["probe", "probe", "probe", "plan", "teach"]);
});
test("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => {
const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]);
assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split");
@@ -158,23 +168,31 @@ function assistantQuizCall(arguments_: Record<string, unknown>): LhMessage {
} as unknown as LhMessage;
}
function assistantProbeCall(): LhMessage {
return {
...assistantQuizCall({}),
content: [{ type: "toolCall", id: "call_probe_done", name: "probe_done", arguments: {} }],
} as LhMessage;
}
test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => {
const done = toolResult("probe_done");
const call = assistantProbeCall();
const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" });
const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged quiz call never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged quiz CALL is the seam");
assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged quiz call never starts teaching");
assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged quiz CALL is the seam");
assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)");
assert.equal(
phaseAt3Lh([toolResult("probe_done", true), tagged]),
phaseAt3Lh([call, toolResult("probe_done", true), tagged]),
"probe",
"an ERRORED probe_done concludes nothing",
);
// The two folds agree on the same settled transcript (the view projection
// of the tagged call carries its arguments as the part input).
assert.equal(
phaseAt3Lh([done, tagged]),
phaseAt3Lh([call, done, tagged]),
phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] },
@@ -188,6 +206,7 @@ test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; pla
test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => {
const done = toolResult("probe_done");
const call = assistantProbeCall();
const explainCall = (args: Record<string, unknown>): LhMessage =>
({
id: "a_explain",
@@ -199,10 +218,10 @@ test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too"
const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" });
const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged explain never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged explain CALL is the seam");
assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged explain never starts teaching");
assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged explain CALL is the seam");
assert.equal(
phaseAt3Lh([done, tagged]),
phaseAt3Lh([call, done, tagged]),
phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] },
+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);
}
+61 -2
View File
@@ -13,12 +13,13 @@ import * as path from "node:path";
import { after, describe, it } from "node:test";
import { probeDoneTool } from "../src/agent/tools/probe.ts";
import type { SteerMessage } from "../src/core/contracts.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import { hasProbeDone, type LhMessage } from "../src/core/native-messages.ts";
import { phaseAt3Lh } from "../src/core/phases.ts";
import { HANDOFF_TEXT } from "../src/core/contracts.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
const dataDir = path.join(PATHS.root, "data");
const dataDir = PATHS.data;
fs.mkdirSync(dataDir, { recursive: true });
const createdChats: string[] = [];
@@ -142,3 +143,61 @@ describe("probe_done: the probe→planning seam", () => {
assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam");
});
});
/** A real record result, distinct from the calling assistant message. */
function probeResult(overrides: Partial<Extract<LhMessage, { role: "toolResult" }>> = {}): LhMessage {
return {
id: "probe-result", role: "toolResult", toolCallId: "call_probe", toolName: "probe_done",
content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1,
...overrides,
};
}
const probeCall = () => assistantWithCall("probe", "probe_done", { assessment: ASSESSMENT });
const failedProbeCall = (stopReason: "error" | "aborted"): LhMessage => ({ ...probeCall(), stopReason } as LhMessage);
const incompleteProbes: Array<[string, () => LhMessage[]]> = [
["unmatched call", () => [probeCall()]],
["errored result", () => [probeCall(), probeResult({ isError: true })]],
["explicit refusal without isError", () => [probeCall(), probeResult({ details: { ok: false, error: "refused" } })]],
["result without a call", () => [probeResult()]],
["wrong call id", () => [probeCall(), probeResult({ toolCallId: "other" })]],
["wrong result tool name", () => [probeCall(), probeResult({ toolName: "plan_init" })]],
["wrong call tool name", () => [assistantWithCall("probe", "plan_init", {}), probeResult()]],
["result before the call", () => [probeResult(), probeCall()]],
["failed calling generation", () => [failedProbeCall("error"), probeResult({ details: { ok: true } })]],
["aborted calling generation", () => [failedProbeCall("aborted"), probeResult({ details: { ok: true } })]],
];
describe("probe completion: matched successful results only (PR130 foundation)", () => {
for (const [name, messages] of incompleteProbes) {
it(`${name} keeps probing open and allows a fresh conclusion`, async () => {
const chatId = newChat(`Probe recovery: ${name}`);
const before = messages();
const snapshot = structuredClone(before);
chatStore.saveMessages(chatId, before);
assert.equal(hasProbeDone(before), false);
assert.equal(phaseAt3Lh(before), "probe");
const delivered: SteerMessage[] = [];
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
assert.equal((await execute(tools, { assessment: ASSESSMENT })).ok, true);
assert.equal(delivered.length, 1, "only the fresh invocation queues a handoff");
assert.deepEqual(before, snapshot, "the scans never mutate history");
assert.deepEqual(chatStore.getChat(chatId)!.messages, snapshot, "the tool does not delete or replay saved attempts");
});
}
for (const [name, result] of [
["current acknowledgment", probeResult({ details: { ok: true } })],
["older clean acknowledgment without details", probeResult()],
["older clean acknowledgment without ok", probeResult({ details: { assessment: ASSESSMENT } })],
] as const) {
it(`${name} concludes exactly at the matching result`, () => {
const messages = [probeCall(), result];
const before = structuredClone(messages);
assert.equal(hasProbeDone(messages), true);
assert.equal(phaseAt3Lh(messages, 0), "probe", "a future result cannot conclude an earlier prefix");
assert.equal(phaseAt3Lh(messages, 1), "plan");
assert.deepEqual(messages, before);
});
}
});
+3 -2
View File
@@ -1,4 +1,4 @@
/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */
/** Real installed Pi/Google/Bedrock SDKs only; no provider stubs, keys, or generation. */
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
@@ -12,11 +12,12 @@ const checkout = fileURLToPath(new URL("..", import.meta.url));
const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 });
describe("offline native Pi Google initialization gate", () => {
it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => {
it("reaches the sentinel for Google, Google-backed OpenCode and Bedrock adapters using the real SDKs", () => {
// This is an unbundled positive control, NOT proof of standalone completeness.
const result = run(checkout);
assert.equal(result.status, 0, String(result.stderr));
for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`));
assert.ok(String(result.stdout).includes("amazon-bedrock: native lazy Bedrock adapter reached onPayload"));
assert.match(String(result.stdout), /zero network attempts/);
});
it("rejects a missing Pi entry instead of treating any error as success", () => {
+5 -2
View File
@@ -16,7 +16,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative);
const ignored = picomatch(patterns, { contains: true, dot: true, windows });
it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => {
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) {
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local", "chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json", "chatgpt-credentials.json.fixture.tmp", "github-copilot-credentials.json.fixture.tmp", "xai-credentials.json.fixture.tmp"]) {
assert.equal(ignored(file(relative)), true, relative);
}
// The explicit includes remain necessary: this raw ignore still matches SDK data.
@@ -37,7 +37,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false);
});
it("preserves all secret exclusions exactly", () => {
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]);
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", "chatgpt-credentials.json*", "github-copilot-credentials.json*", "xai-credentials.json*", ".env*"]);
});
it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => {
assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [
@@ -45,6 +45,9 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
"node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*",
"node_modules/@napi-rs/canvas-*/**/*",
"src/agent/image-processing/*.mjs",
"src/agent/image-processing/LICENSE",
"node_modules/@silvia-odwyer/photon-node/**/*",
"node_modules/@resvg/resvg-js-*/**/*",
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
+7 -3
View File
@@ -10,10 +10,12 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { after, describe, it } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { readTool } from "../src/agent/tools/read.ts";
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
function write(name: string, content: string): string {
const file = path.join(tmp, name);
@@ -100,8 +102,10 @@ describe("read tool — text semantics", () => {
describe("read tool — image semantics (native content parts)", () => {
it("returns native text + image parts for image files, details null (output-less part)", async () => {
const file = path.join(tmp, "dot.png");
// minimal valid PNG header bytes are enough — detection is by extension
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
// Detection now sniffs bytes and the native processor decodes the image.
const canvas = createCanvas(1, 1);
canvas.getContext("2d").fillRect(0, 0, 1, 1);
fs.writeFileSync(file, canvas.toBuffer("image/png"));
const result = await readTool.execute!("test", { path: file });
assert.equal(result.content.length, 2);
const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
+27 -2
View File
@@ -78,6 +78,31 @@ function renderDock(props: {
);
}
describe("question submission draft restoration", () => {
const draft = {
selection: { selected: new Set([0]), otherSelected: true, dontKnow: false },
thinking: "preserved reasoning",
other: "preserved custom answer",
text: "preserved free-text answer",
confidenceIndex: 4,
};
it("restores a quiz selection, thinking, and confidence when its form remounts", () => {
const html = renderToStaticMarkup(<QuestionDock toolName="quiz" toolCallId="q1" input={quizInput} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.match(html, /role="radio" aria-checked="true" data-kbdrow="0"/);
assert.ok(html.includes("preserved reasoning"));
assert.ok(html.includes("90%"));
});
it("restores an ask's custom answer and a free-text answer", () => {
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.match(custom, /role="textbox"[^>]*aria-multiline="false"[^>]*>preserved custom answer<\/div>/);
assert.match(custom, /aria-checked="true" data-testid="other-option"/);
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(text.includes("preserved free-text answer"));
});
});
describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
const job = (over: Partial<JobRecord>): JobRecord => ({
id: "j1",
@@ -131,9 +156,9 @@ describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
assert.ok(!html.includes("dispatch-refused"));
});
it("a delivered job reads delivered, untouched by the wave", () => {
it("a finished worker reads finished, independently of delivery", () => {
const html = renderToStaticMarkup(inJobs([job({ status: "done", error: undefined })]));
assert.ok(html.includes("delivered"));
assert.ok(html.includes("finished"));
});
});
+56 -4
View File
@@ -2,9 +2,24 @@ import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import { afterEach, beforeEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { DraftShell, startDraftSession } from "../components/new-session.tsx";
import { clearOpeningPrompt, readOpeningPrompt, OPENING_PROMPT_FAILURE } from "../components/opening-prompt.ts";
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
beforeEach(() => {
const data = new Map<string, string>();
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => { data.set(key, value); },
removeItem: (key: string) => { data.delete(key); },
} });
});
afterEach(() => {
if (originalStorage) Object.defineProperty(globalThis, "sessionStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "sessionStorage");
});
/**
* The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session"
@@ -25,10 +40,10 @@ function fakeFetch(id: string) {
}
describe("startDraftSession — the create-on-send contract", () => {
it("creates the session titled with the first message, returns the prompt redirect", async () => {
it("creates the session titled with the first message and returns a short path", async () => {
const { f, calls } = fakeFetch("c_new");
const target = await startDraftSession("teach me orbital mechanics", f);
assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics");
assert.equal(target, "/chat/c_new");
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, "/api/chats");
assert.equal(calls[0]!.method, "POST");
@@ -41,6 +56,37 @@ describe("startDraftSession — the create-on-send contract", () => {
assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side");
});
it("a long opening text stays out of the URL and is stashed for the chat page", async () => {
const mem = new Map<string, string>();
const fake = {
getItem: (k: string) => mem.get(k) ?? null,
setItem: (k: string, v: string) => {
mem.set(k, v);
},
removeItem: (k: string) => {
mem.delete(k);
},
};
const holder = globalThis as { sessionStorage?: Storage };
const prev = holder.sessionStorage;
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: fake });
try {
const { f } = fakeFetch("c_long");
const text = `${"B2 First grammar item. ".repeat(1500)}end`;
const target = await startDraftSession(text, f);
assert.equal(target, "/chat/c_long");
assert.equal(target!.includes("?"), false);
const stashed = readOpeningPrompt("c_long");
assert.equal(stashed?.length, text.length);
assert.equal(stashed, text);
clearOpeningPrompt("c_long");
assert.equal(readOpeningPrompt("c_long"), undefined);
} finally {
if (prev === undefined) delete holder.sessionStorage;
else Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: prev });
}
});
it("empty or whitespace text never creates anything", async () => {
const { f, calls } = fakeFetch("never");
assert.equal(await startDraftSession("", f), null);
@@ -50,6 +96,12 @@ describe("startDraftSession — the create-on-send contract", () => {
});
describe("DraftShell — the sessionless surface", () => {
it("storage failure leaves the full draft editable beside the approved error", () => {
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
assert.ok(html.includes('role="alert"'));
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
assert.match(html, /<div[^>]*role="textbox"[^>]*contentEditable="true"[^>]*>keep all my text<\/div>/);
});
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
const html = renderToStaticMarkup(
<DraftShell
@@ -188,7 +240,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => {
return new Response(JSON.stringify({ id: "c9" }), { status: 201 });
}) as unknown as typeof fetch;
const target = await startDraftSession("the parity chain", f, "practice");
assert.equal(target, "/chat/c9?prompt=the%20parity%20chain");
assert.equal(target, "/chat/c9");
assert.deepEqual(body, { title: "the parity chain", kind: "practice" });
});
+60
View File
@@ -19,6 +19,9 @@ import { KeyboardSection } from "../components/settings/keyboard-section.tsx";
import type { GraphSettings } from "../src/core/index.ts";
import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
import { DictationLanguageSetting } from "../components/dictation-language.tsx";
import { SettingsMenuTrigger, SettingsRow } from "../components/settings/controls.tsx";
import { SubscriptionConnections } from "../components/settings/subscription-connections.tsx";
/** The full opening tag of the element carrying a testid — attribute
* order in React output is not contractual, so assertions run on the
@@ -29,6 +32,17 @@ function tagOf(html: string, testid: string): string {
return html.slice(html.lastIndexOf("<", i), html.indexOf(">", i) + 1);
}
describe("SettingsRow hint composition", () => {
it("preserves rich React hints while translating only text", () => {
const rich = renderToStaticMarkup(<SettingsRow label="Hint" hint={<a href="#details">Details</a>}><input /></SettingsRow>);
assert.match(rich, /<a href="#details">Details<\/a>/);
const text = renderToStaticMarkup(<SettingsRow label="Hint" hint="A text hint"><input /></SettingsRow>);
assert.match(text, /A text hint/);
const numeric = renderToStaticMarkup(<SettingsRow label="Hint" hint={42}><input /></SettingsRow>);
assert.match(numeric, />42<\/span>/);
});
});
describe("SettingsDialog (the designed surface, SSR)", () => {
it("provider CLOSED: children only — no dialog, no portal content", () => {
const html = renderToStaticMarkup(
@@ -60,6 +74,52 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
});
});
describe("the shared settings dropdown trigger", () => {
it("Agent, Appearance and dictation use the same compact dark mono trigger", () => {
const surfaces = [
[renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />), "slot-model-strong"],
[renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />), "font-trigger"],
[renderToStaticMarkup(<DictationLanguageSetting />), "language-select-sttLanguage"],
] as const;
for (const [html, id] of surfaces) {
const tag = tagOf(html, id);
const classes = tag.match(/class="([^"]+)"/)![1]!.split(" ");
for (const token of ["h-7", "rounded-sm", "border", "border-border", "bg-background", "px-1.5", "font-ui", "text-xs", "hover:border-muted-foreground/50"]) {
assert.ok(classes.includes(token), `${id}: ${token}`);
}
assert.ok(html.includes("▾"), `${id}: shared dropdown indicator`);
}
});
it("preserves native disabling separately from callers' existing click-gated unavailable state", () => {
const disabled = tagOf(renderToStaticMarkup(<SettingsMenuTrigger value="English" disabled data-testid="disabled-trigger" />), "disabled-trigger");
const unavailable = tagOf(renderToStaticMarkup(<SettingsMenuTrigger value="Automatic" unavailable data-testid="unavailable-trigger" />), "unavailable-trigger");
assert.ok(disabled.includes('disabled=""'));
assert.ok(!unavailable.includes('disabled=""'));
assert.ok(unavailable.includes("cursor-not-allowed"));
assert.ok(!unavailable.includes("hover:border-muted-foreground/50"));
});
});
describe("Providers settings-label typography", () => {
it("the fork's speech-language label keeps the standard sans SettingsRow styling and mono trigger", () => {
const html = renderToStaticMarkup(<DictationLanguageSetting />);
assert.ok(html.includes('<span class="block font-prose text-(length:--lh-prose-size)">Speech recognition language</span>'));
assert.ok(tagOf(html, "language-select-sttLanguage").includes("font-ui text-xs"));
});
it("only Providers subscription labels use sans; setup labels retain their compact mono styling", () => {
const settings = renderToStaticMarkup(<SubscriptionConnections />);
const setup = renderToStaticMarkup(<SubscriptionConnections setup />);
for (const label of ["ChatGPT", "GitHub Copilot", "Grok"]) {
assert.ok(settings.includes(`<span class="font-prose text-(length:--lh-prose-size) text-foreground">${label}</span>`), `${label}: settings sans label`);
assert.ok(setup.includes(`<span class="font-ui text-xs text-foreground">${label}</span>`), `${label}: unchanged setup label`);
}
assert.equal((settings.match(/>Connect<\/button>/g) ?? []).length, 3);
assert.equal((setup.match(/>Connect<\/button>/g) ?? []).length, 3);
});
});
describe("AgentSection (thin SSR)", () => {
it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
+12 -4
View File
@@ -89,9 +89,15 @@ describe("stats bar", () => {
assert.match(empty, /data-testid="stats-context">\?</);
});
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902");
assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free");
assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch");
assert.equal(prettyModel("claude-opus-5"), "claude-opus-5");
assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5");
assert.equal(prettyModel("model:free"), "model:free");
assert.equal(prettyModel("model:variant:free"), "model:variant:free");
});
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
@@ -99,8 +105,10 @@ describe("stats bar", () => {
// the same model via two providers different bookkeeping
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter");
assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai");
// unqualified on a qualified stamp: provider silently dropped (old shape)
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash");
assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter");
assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode");
// Bare ids are never parsed as usage stamps, even with a colon.
assert.equal(prettyModel("model:free"), "model:free");
// qualified on a bare id: no suffix invented
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
});
+140
View File
@@ -0,0 +1,140 @@
import React, { act } from "react";
(globalThis as { React?: typeof React }).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx";
import { Reasoning, ReasoningContent } from "../components/ai-elements/reasoning.tsx";
import { transformMathDelimiters } from "../src/core/math-delims.ts";
const surfaces = {
message: (source: string) => <MessageResponse>{source}</MessageResponse>,
tool: (source: string) => <ToolContentResponse>{source}</ToolContentResponse>,
reasoning: (source: string) => <Reasoning open><ReasoningContent>{source}</ReasoningContent></Reasoning>,
};
const documentFor = (element: React.ReactElement) =>
parseHTML(`<html><body>${renderToStaticMarkup(element)}</body></html>`).document;
const annotations = (root: ParentNode) => [...root.querySelectorAll('annotation[encoding="application/x-tex"]')].map(node => node.textContent);
// PR266 identified the less-than + letter trigger. Test the actual streaming
// render seam, not a TeX-space workaround: valid TeX must remain byte-identical.
for (const [name, surface] of Object.entries(surfaces)) describe(`streaming math — ${name}`, () => {
for (const [source, tex] of [
["$a<b$", "a<b"],
["$$a<b$$", "a<b"],
["$$\na<b\n$$", "a<b"],
[String.raw`\(a<b\)`, "a<b"],
[String.raw`\[a<b\]`, "a<b"],
["$a</b$", "a</b"],
[String.raw`$\text{a<b}$`, String.raw`\text{a<b}`],
[String.raw`$\verb|a<b|$`, String.raw`\verb|a<b|`],
]) it(`retains ${JSON.stringify(source)} and subsequent prose`, () => {
const document = documentFor(surface(`${source}\n\nTAIL`));
assert.deepEqual(annotations(document), [tex]);
assert.ok(document.body.textContent.includes("TAIL"));
assert.ok(!document.body.textContent.includes("[blocked]"));
assert.equal(document.querySelectorAll(".katex-error").length, 0);
});
it("retains probability brackets, both formulas, and the following question", () => {
const source = String.raw`$F_X(2)=0.60$이라고 하자. $P[-1<X\le2]$는 얼마일까?`;
const document = documentFor(surface(source));
assert.deepEqual(annotations(document), ["F_X(2)=0.60", String.raw`P[-1<X\le2]`]);
assert.ok(document.body.textContent.includes("는 얼마일까?"));
assert.ok(!document.body.textContent.includes("[blocked]"));
});
it("does not discard unfinished native or legacy math and its trailing text", () => {
for (const source of ["$a<b TAIL", "$$\na<b\nTAIL", String.raw`\(a<b TAIL`, String.raw`\[a<b TAIL`]) {
const document = documentFor(surface(source));
assert.ok(document.body.textContent.includes("TAIL"), source);
assert.ok(document.body.textContent.includes("<"), source);
}
});
it("leaves inline, fenced, and indented code literal", () => {
for (const source of ["`$a<b$` TAIL", "```text\n$a<b$\n```\n\nTAIL", " $a<b$\n\nTAIL"]) {
const document = documentFor(surface(source));
assert.deepEqual(annotations(document), []);
assert.ok(document.querySelector("code")?.textContent?.includes("$a<b$"), source);
assert.ok(document.body.textContent.includes("TAIL"), source);
}
});
it("shows unfinished HTML literally but keeps complete HTML formatting", () => {
const partial = documentFor(surface("before <b TAIL"));
assert.equal(partial.body.textContent, "before <b TAIL");
assert.equal(partial.querySelectorAll("b").length, 0);
const complete = documentFor(surface("before <b>bold</b> TAIL"));
assert.equal(complete.querySelector("b")?.textContent, "bold");
assert.equal(complete.body.textContent, "before bold TAIL");
});
it("keeps the other incomplete-Markdown repairs", () => {
const bold = documentFor(surface("**bold TAIL"));
assert.equal(bold.querySelector("strong")?.textContent, "bold TAIL");
const link = documentFor(surface("[partial TAIL"));
assert.ok(link.body.textContent.includes("partial TAIL"));
assert.ok(link.body.textContent.includes("[blocked]"), "the incomplete-link placeholder is still hardened");
});
it("still removes script/event attributes and blocks javascript links", () => {
const document = documentFor(surface('before <script>alert(1)</script> <b onclick="alert(1)">bold</b> [unsafe](javascript:alert) TAIL'));
assert.equal(document.querySelectorAll("script, [onclick]").length, 0);
assert.ok(![...document.querySelectorAll("[href]")].some(node => node.getAttribute("href")?.startsWith("javascript:")));
assert.ok(!document.body.textContent.includes("alert(1)"));
assert.ok(document.body.textContent.includes("TAIL"));
assert.ok(document.body.textContent.includes("[blocked]"));
});
});
it("preserves native TeX source in both streaming and static modes", () => {
const source = String.raw`$a<b$ $\text{a<b}$ $\verb|a<b|$`;
assert.equal(transformMathDelimiters(source), source);
const streaming = documentFor(<MessageResponse>{source}</MessageResponse>);
const staticDocument = documentFor(<MessageResponse mode="static">{source}</MessageResponse>);
assert.deepEqual(annotations(streaming), ["a<b", String.raw`\text{a<b}`, String.raw`\verb|a<b|`]);
assert.deepEqual(annotations(streaming), annotations(staticDocument));
assert.deepEqual([...streaming.querySelectorAll(".katex-html")].map(node => node.innerHTML),
[...staticDocument.querySelectorAll(".katex-html")].map(node => node.innerHTML));
});
// Exercise successive updates on the SAME mounted components, rather than
// mistaking independent server renders for incremental streaming coverage.
it("retains less-than prefixes through mounted streaming updates on every entry point", async () => {
const dom = parseHTML("<html><body></body></html>");
// linkedom has no layout/animation engine; Radix's open reasoning panel
// only needs these inert browser hooks. This test checks text, not layout.
const globals = { window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true,
getComputedStyle: () => ({ display: "block", animationName: "none" }),
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
cancelAnimationFrame: (handle: ReturnType<typeof setTimeout>) => clearTimeout(handle) };
const previous = Object.getOwnPropertyDescriptors(globalThis);
Object.assign(globalThis, globals);
try {
for (const [name, surface] of Object.entries(surfaces)) {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
for (const source of ["$a", "$a<", "$a<b", "$a<b TAIL", "$a<b$ TAIL"]) {
await act(async () => root.render(surface(source)));
if (source.includes("<b")) assert.ok(host.textContent?.includes("<b"), `${name}: ${source}`);
if (source.includes("TAIL")) assert.ok(host.textContent?.includes("TAIL"), `${name}: ${source}`);
assert.ok(!host.textContent?.includes("[blocked]"), `${name}: ${source}`);
}
assert.deepEqual(annotations(host), ["a<b"]);
} finally {
await act(async () => root.unmount());
host.remove();
}
}
} finally {
for (const key of Object.keys(globals)) {
if (previous[key]) Object.defineProperty(globalThis, key, previous[key]);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+8 -7
View File
@@ -1,7 +1,7 @@
/**
* Update-banner render tests (Wave C, 2026-10-10): the banner is a pure
* function of the last lh:update-status push — hidden without one (the
* browser/dev world: no window.lh at all), visible with the downloaded
* function of the shell's observational status — hidden without a bridge
* or snapshot/event, visible with the downloaded
* status, Restart wired to the bridge. The subscribe effect only runs
* client-side, so the SSR pass pins the hidden state; the pushed state is
* exercised by mounting with the bridge stubbed (classic-JSX shim as in
@@ -17,6 +17,7 @@ import * as ReactDOMClient from "react-dom/client";
import { flushSync } from "react-dom";
import { parseHTML } from "linkedom";
import UpdateBanner from "../components/update-banner.tsx";
import type { UpdateState } from "../components/settings/update-settings.tsx";
// linkedom as the DOM for the interactive pass (first createRoot test in
// the repo — the banner's push→render→click flow needs real effects).
@@ -36,9 +37,9 @@ describe("update banner (Wave C)", () => {
});
it("renders nothing when the bridge exists but no status has arrived", () => {
const listeners: Array<(s: { status: string; version: string | null }) => void> = [];
const listeners: Array<(s: UpdateState) => void> = [];
(window as { lh?: unknown }).lh = {
onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => {
onUpdateStatus: (cb: (s: UpdateState) => void) => {
listeners.push(cb);
return () => {};
},
@@ -50,10 +51,10 @@ describe("update banner (Wave C)", () => {
});
it("shows the version + Restart once the downloaded push arrives, and Restart installs", async () => {
let pushed: ((s: { status: string; version: string | null }) => void) | null = null;
let pushed: ((s: UpdateState) => void) | null = null;
let installed = 0;
(window as { lh?: unknown }).lh = {
onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => {
onUpdateStatus: (cb: (s: UpdateState) => void) => {
pushed = cb;
return () => {};
},
@@ -68,7 +69,7 @@ describe("update banner (Wave C)", () => {
assert.equal(host.innerHTML, "", "hidden before the push");
await new Promise((r) => setTimeout(r, 0));
flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1" }));
flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1", revision: 1 }));
assert.ok(host.innerHTML.includes("0.3.1"), "the version renders");
assert.ok(host.innerHTML.includes("Restart"), "the restart affordance renders");
+225
View File
@@ -0,0 +1,225 @@
/** Update UI with a fake preload bridge: no Electron, network or updates. */
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
import assert from "node:assert/strict";
import { describe, it, afterEach } from "node:test";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { UpdateSettings, updateStatusText, MANUAL_UPDATE_HINT, AUTO_UPDATE_HINT, type UpdateState } from "../components/settings/update-settings.tsx";
import UpdateBanner from "../components/update-banner.tsx";
import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import { I18nProvider } from "../components/i18n.tsx";
import nextConfig from "../next.config.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent });
const originalFetch = globalThis.fetch;
const roots: { root: Root; host: HTMLElement }[] = [];
const state = (over: Partial<UpdateState> = {}): UpdateState => ({ status: "idle", version: null, revision: 0, ...over });
const props = { autoUpdate: true, onAutoUpdateChange: () => {}, version: "0.3.7", homepage: "https://github.com/amosblomqvist/learnharness" };
const q = (host: ParentNode, id: string) => host.querySelector<HTMLElement>(`[data-testid='${id}']`);
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function bridge(initial = state(), snapshot = Promise.resolve(initial)) {
const listeners = new Set<(value: UpdateState) => void>();
const calls = { check: 0, install: 0, get: 0 };
let onOpen: ((section: string) => void) | null = null;
let checkFailure: Error | null = null;
window.lh = {
expandWindow() {},
getUpdateState: () => { calls.get++; return snapshot; },
onUpdateStatus: cb => { listeners.add(cb); return () => { listeners.delete(cb); }; },
checkForUpdates: async () => { calls.check++; if (checkFailure) throw checkFailure; },
installUpdate: () => { calls.install++; },
onOpenSettings: cb => { onOpen = cb; return () => { onOpen = null; }; },
};
return {
calls, listeners,
failCheck: () => { checkFailure = new Error("sensitive raw diagnostics"); },
push: async (value: UpdateState) => { await act(async () => { for (const listener of listeners) listener(value); }); },
open: async () => { await act(async () => onOpen?.("general")); },
};
}
async function mount(element: React.ReactElement) {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host); roots.push({ root, host });
await act(async () => { root.render(element); });
return host;
}
async function click(element: HTMLElement | null) {
assert.ok(element, "missing click target");
await act(async () => { element.click(); });
}
afterEach(async () => {
for (const { root, host } of roots.splice(0)) {
await act(async () => { root.unmount(); }); host.remove();
}
delete window.lh;
globalThis.fetch = originalFetch;
});
describe("version and contextual copy", () => {
it("build metadata comes from package.json and exposes only public version/homepage values", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
assert.deepEqual(nextConfig.env, { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage });
});
it("never claims live/latest information and keeps updater errors generic", () => {
assert.equal(updateStatusText(null), null);
assert.equal(updateStatusText(state()), null);
assert.equal(updateStatusText(state({ status: "disabled" })), null);
assert.equal(updateStatusText(state({ status: "checking" })), "Checking for updates…");
assert.equal(updateStatusText(state({ status: "downloading", version: "0.3.8" })), "Downloading version 0.3.8…");
assert.equal(updateStatusText(state({ status: "up-to-date" })), "No newer version was found at the last check.");
assert.equal(updateStatusText(state({ status: "downloaded", version: "0.3.8" })), "Version 0.3.8 is ready. It will install when you quit the app.");
assert.equal(updateStatusText(state({ status: "error" })), "Couldn’t check for updates. Try again later.");
assert.equal(updateStatusText(state({ status: "downloading" })), "Downloading an update…");
assert.equal(updateStatusText(state({ status: "downloaded" })), "An update is ready. It will install when you quit the app.");
});
});
describe("General update rows", () => {
it("browser/SSR shows version and installed-version release notes, without inactive controls or claims", () => {
const html = renderToStaticMarkup(<UpdateSettings {...props} />);
assert.ok(html.includes("Version 0.3.7"));
assert.ok(html.includes(`${props.homepage}/releases/tag/v0.3.7`));
assert.ok(!html.includes("update-check")); assert.ok(!html.includes("row-auto-update"));
assert.ok(!html.includes(MANUAL_UPDATE_HINT)); assert.ok(!html.includes("update-status"));
});
it("unpackaged/unavailable updater shows the version-only surface", async () => {
const b = bridge(state({ status: "disabled" })); const host = await mount(<UpdateSettings {...props} />);
assert.ok(q(host, "row-version")); assert.equal(q(host, "update-check"), null);
assert.equal(q(host, "row-auto-update"), null); assert.equal(b.calls.check, 0);
});
it("idle shows two rows and clear consequences; opening Settings never checks", async () => {
const b = bridge(); const seen: boolean[] = [];
const host = await mount(<UpdateSettings {...props} onAutoUpdateChange={value => seen.push(value)} />);
assert.deepEqual([...host.children].map(el => el.getAttribute("data-testid")), ["row-version", "row-auto-update"]);
assert.ok(q(host, "row-version")!.contains(q(host, "update-check")));
assert.ok(host.textContent!.includes(MANUAL_UPDATE_HINT)); assert.ok(host.textContent!.includes(AUTO_UPDATE_HINT));
assert.equal(q(host, "update-status"), null); assert.equal(b.calls.check, 0);
await click(q(host, "toggle-auto-update")); assert.deepEqual(seen, [false]);
await click(q(host, "update-check")); assert.equal(b.calls.check, 1);
});
it("manual check remains available while automatic checks are OFF", async () => {
const b = bridge(); const host = await mount(<UpdateSettings {...props} autoUpdate={false} />);
assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false");
await click(q(host, "update-check")); assert.equal(b.calls.check, 1);
});
it("checking/downloading shows context and disables only the UI button; ready offers Restart", async () => {
const b = bridge(); const host = await mount(<UpdateSettings {...props} />);
await b.push(state({ status: "checking", revision: 1 }));
assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, true);
assert.equal(q(host, "update-check")!.getAttribute("aria-busy"), "true");
assert.equal(q(host, "update-status")!.textContent, "Checking for updates…");
await b.push(state({ status: "downloading", version: "0.3.8", revision: 2 }));
assert.equal(q(host, "update-status")!.textContent, "Downloading version 0.3.8…");
assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, true);
await b.push(state({ status: "downloaded", version: "0.3.8", revision: 3 }));
assert.equal(q(host, "update-check"), null); assert.ok(q(host, "update-status")!.textContent!.includes("quit the app"));
await click(q(host, "update-restart")); assert.equal(b.calls.install, 1);
});
it("up-to-date and failure statuses allow another manual check", async () => {
const b = bridge(); const host = await mount(<UpdateSettings {...props} />);
await b.push(state({ status: "up-to-date", revision: 1 }));
assert.equal(q(host, "update-status")!.textContent, "No newer version was found at the last check.");
await b.push(state({ status: "error", revision: 2 }));
assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, false);
assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later.");
await click(q(host, "update-check")); assert.equal(b.calls.check, 1);
});
it("bridge command rejection is handled without exposing raw diagnostics", async () => {
const b = bridge(); b.failCheck(); const host = await mount(<UpdateSettings {...props} />);
await click(q(host, "update-check"));
assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later.");
assert.ok(!host.textContent!.includes("sensitive"));
});
it("late Settings and banner subscribers recover the staged update without an event", async () => {
const b = bridge(state({ status: "downloaded", version: "0.3.8", revision: 5 }));
const host = await mount(<><UpdateSettings {...props} /><UpdateBanner /></>);
assert.ok(q(host, "update-restart")); assert.ok(q(host, "update-banner"));
assert.ok(q(host, "update-banner")!.textContent!.includes("0.3.8"));
await click(q(host, "update-banner")!.querySelector("button")); assert.equal(b.calls.install, 1);
assert.equal(b.calls.check, 0);
});
it("a delayed older snapshot cannot overwrite a newer push in Settings or the banner", async () => {
const snapshot = deferred<UpdateState>(); const b = bridge(state(), snapshot.promise);
const host = await mount(<><UpdateSettings {...props} /><UpdateBanner /></>);
await b.push(state({ status: "downloaded", version: "0.3.8", revision: 4 }));
await act(async () => { snapshot.resolve(state({ status: "checking", revision: 3 })); });
assert.ok(q(host, "update-restart")); assert.ok(q(host, "update-banner"));
});
it("snapshot rejection cannot erase an already received event and late callbacks are unsubscribed", async () => {
const snapshot = deferred<UpdateState>(); const b = bridge(state(), snapshot.promise);
const host = await mount(<UpdateSettings {...props} />);
await b.push(state({ status: "downloaded", version: "0.3.8", revision: 1 }));
await act(async () => { snapshot.reject(new Error("IPC disconnected")); });
assert.ok(q(host, "update-restart"));
const { root } = roots.pop()!; await act(async () => { root.unmount(); }); host.remove();
assert.equal(b.listeners.size, 0);
});
it("snapshot rejection without an event is handled with a generic message", async () => {
const snapshot = deferred<UpdateState>(); bridge(state(), snapshot.promise);
const host = await mount(<UpdateSettings {...props} />);
await act(async () => { snapshot.reject(new Error("sensitive error")); });
assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later.");
assert.ok(!host.textContent!.includes("sensitive"));
});
});
describe("Settings integration", () => {
it("the toggle sends the complete General section and follows stored server truth", async () => {
bridge();
// A synthetic future sibling exercises the caller's section-preserving
// contract without introducing a new product setting or changing its API.
const record = { version: 1, general: { autoUpdate: true, futureSibling: "keep" } };
const puts: unknown[] = [];
globalThis.fetch = async (input, init) => {
assert.equal(String(input), init?.method === "PUT" ? "/api/settings/general" : "/api/settings");
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body)); puts.push(body);
record.general = body;
return Response.json({ settings: record });
}
return Response.json({ settings: record });
};
const host = await mount(<I18nProvider initialGeneral={record.general}><SettingsDialogBody initialSection="general" onClose={() => {}} /></I18nProvider>);
await click(q(host, "toggle-auto-update"));
assert.deepEqual(puts, [{ autoUpdate: false, futureSibling: "keep" }]);
assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false");
});
it("the shell's navigation request opens General, but opening alone never checks", async () => {
const b = bridge();
globalThis.fetch = async () => Response.json({ settings: { version: 1 } });
const host = await mount(<SettingsDialogProvider><span>app</span></SettingsDialogProvider>);
assert.equal(q(document, "settings-dialog"), null);
await b.open();
assert.equal(q(document, "settings-nav-general")!.getAttribute("aria-current"), "true");
assert.ok(q(document, "row-version")); assert.equal(b.calls.check, 0);
assert.ok(host.textContent!.includes("app"));
});
});
+8 -8
View File
@@ -12,7 +12,7 @@ import { PracticeDock } from "../components/practice-dock.tsx";
import { QuestionDock } from "../components/question-dock.tsx";
import { LatexGlyph } from "../components/latex-glyph.tsx";
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
import { MessageResponse } from "../components/ai-elements/message.tsx";
import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx";
import {
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
@@ -206,17 +206,17 @@ describe("practice display surfaces", () => {
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
});
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
it("teaching shape parsing retains source bytes; presentation alone recovers them", () => {
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
const ask = { question: doubled };
const explain = { question: doubled, correctAnswer: debris };
const before = structuredClone({ quiz, ask, explain });
assert.equal(parseQuizInput(quiz)?.question, clean);
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
assert.equal(parseAskInput(ask)?.question, clean);
assert.equal(parseExplainInput(explain)?.question, clean);
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
assert.equal(parseQuizInput(quiz)?.question, doubled);
assert.equal(parseQuizInput(quiz)?.explanation, debris);
assert.equal(parseAskInput(ask)?.question, doubled);
assert.equal(parseExplainInput(explain)?.question, doubled);
assert.equal(parseExplainInput(explain)?.correctAnswer, debris);
assertMath(renderToStaticMarkup(<ToolContentResponse>{parseAskInput(ask)!.question}</ToolContentResponse>));
assert.deepEqual({ quiz, ask, explain }, before);
});
+613
View File
@@ -0,0 +1,613 @@
/**
* Failed-response Retry (issue #110). Input is already durable. Retry
* omits the trailing failures from the temporary agent context, never
* deletes them from the transcript, and appends only genuinely new output.
* Scripted faux engines: no real provider calls or learning data.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it, mock } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
import { Type } from "typebox";
import type { AgentBuildOptions, TeachingAgent } from "../src/agent/teacher-agent.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import type { QuestionData } from "../src/core/question-core.ts";
import type { SteerMessage } from "../src/core/contracts.ts";
// Set the storage/auth seams BEFORE any app/config-loading imports.
assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers.");
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-retry-failed-turn-"));
process.env.LH_DATA_DIR = path.join(scratch, "data");
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
process.env.OPENROUTER_API_KEY = "test-key-presence-gate";
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const { SessionModel } = await import("../components/session-core.ts");
const { createPracticeAgent, createProberAgent, createTeacherAgent } = await import("../src/agent/teacher-agent.ts");
const { handleChatRequest } = await import("../src/server/chat-handler.ts");
const { clearTurnFailure, rememberTurnFailure, runningTurn, turnFailure } = await import("../src/server/turn-runs.ts");
const { isTurnRunning } = await import("../src/server/turn-liveness.ts");
const { chatStore } = await import("../src/store/chats.ts");
const { jobStore } = await import("../src/store/jobs.ts");
const { subscribe } = await import("../src/core/events.ts");
const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts");
const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts");
const { INTERRUPTED_TEXT, asAgentMessages, hasProbeDone, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts");
const { phaseAt3Lh } = await import("../src/core/phases.ts");
const { newCompactionMarker } = await import("../src/core/compaction.ts");
after(() => {
fs.rmSync(scratch, { recursive: true, force: true });
});
const QUIZ_INPUT = {
question: "Which planet is closest to the sun?",
options: [{ label: "Venus" }, { label: "Mercury" }],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
type: "check",
nodeId: "planet-order",
};
const networkFailure: FauxResponseStep = () => { throw new Error("fetch failed"); };
/** Record the actual model context after the app's native conversion. */
function scripted(steps: FauxResponseStep[]) {
const contexts: string[][] = [];
const histories: string[] = [];
const faux = createFauxCore({});
faux.setResponses(steps.map((step) => (context, options, state, model) => {
contexts.push(context.messages.map((m) => m.role).filter((role) => role !== "system"));
histories.push(JSON.stringify(context.messages));
return typeof step === "function" ? step(context, options, state, model) : step;
}));
const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple };
return { engine, contexts, histories };
}
function post(chatId: string, input: unknown): Request {
return new Request("http://test/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input }),
});
}
type AgentFactory = (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent;
/** POST one event; after acceptance drain the server-owned turn. */
async function turn(engine: PiEngine, chatId: string, input: unknown, buildAgent?: AgentFactory): Promise<Response> {
const ack = await handleChatRequest(
post(chatId, input),
buildAgent ?? ((opts) => createTeacherAgent(undefined, { ...opts, engine })),
);
if (ack.status === 200) {
const handle = runningTurn(chatId);
if (handle) await new Response(handle.subscribe()).text();
}
return ack;
}
function createConfiguredChat(title: string, kind?: "practice") {
const chat = chatStore.createChat(title, kind);
chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } });
chatStore.saveTree(chat.id, { nodes: [{ id: "planet-order", title: "Planet order" }], edges: [], version: 1 });
return chat;
}
function gradedAnswer() {
const options = normalizeOptions(QUIZ_INPUT.options);
const qd: QuestionData = {
id: "call-1",
kind: "quiz",
question: QUIZ_INPUT.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(QUIZ_INPUT.correctAnswer, options).indices,
explanation: QUIZ_INPUT.explanation,
createdAt: new Date().toISOString(),
};
return buildAnswer(qd, { values: ["Mercury"], thinking: "Mercury is the innermost", readingThinking: "I followed the planet ordering", confidence: 80 });
}
const roles = (chatId: string) => chatStore.getChat(chatId)!.messages.map((m) => m.role);
const failed = (id: string, text = ""): LhMessage => ({
...fauxAssistantMessage(text, { stopReason: "error", errorMessage: "fetch failed" }), id,
});
const compaction = (firstKeptMessageId: string) => newCompactionMarker("summary", {
firstKeptMessageId, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z",
});
function assertPreserved(before: LhMessage[], saved: LhMessage[]): void {
assert.deepEqual(saved.slice(0, before.length), before, "all previous messages, ids and partial content stay intact");
}
describe("failed-response Retry (issue #110)", () => {
it("retries from the saved answer, preserving the full input and failed attempt", async () => {
const { engine, contexts, histories } = scripted([
() => fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" })], { stopReason: "toolUse" }),
networkFailure,
() => fauxAssistantMessage([fauxText("Right — Mercury it is.")]),
]);
const chat = createConfiguredChat("Test: retry after failed answer");
const answer = gradedAnswer();
await turn(engine, chat.id, { type: "text", text: "teach me the planets" });
await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer });
const before = chatStore.getChat(chat.id)!.messages;
assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]);
assert.ok(endsInFailedTurn(before));
assert.equal((await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer })).status, 400);
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, saved);
assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant", "assistant"]);
assert.equal(endsInFailedTurn(saved), false, "a new successful response supersedes the retained failure");
const results = saved.filter((m) => m.role === "toolResult" && m.toolCallId === "call-1");
assert.equal(results.length, 1, "the learner's answer was not submitted twice");
const result = results[0]!;
assert.ok(result.role === "toolResult");
assert.deepEqual(result.details, JSON.parse(JSON.stringify(answer)));
assert.match(JSON.stringify(saved.at(-1)), /Mercury it is/);
assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]);
assert.match(histories.at(-1)!, /Mercury is the innermost/);
});
it("a failed text turn retries without adding another learner message", async () => {
const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Hello again.")]);
const chat = createConfiguredChat("Test: retry after failed text");
await turn(engine, chat.id, { type: "text", text: "hello" });
const before = chatStore.getChat(chat.id)!.messages;
assert.ok(endsInFailedTurn(before));
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, saved);
assert.deepEqual(roles(chat.id), ["user", "assistant", "assistant"]);
assert.deepEqual(contexts.at(-1), ["user"]);
assert.match(JSON.stringify(saved.at(-1)), /Hello again/);
});
it("preserves partial text and its usage reference before the replacement succeeds", { timeout: 5_000 }, async () => {
const chat = createConfiguredChat("Test: retained partial reply");
const before = [newUserMessage("hello"), failed("partial", "A partial explanation already shown.")];
chatStore.saveMessages(chat.id, before);
const usage = { id: "original-turn", messageId: "partial", at: "2026-01-01T00:00:00Z", model: "faux:fixture", steps: [] };
chatStore.appendTurnUsage(chat.id, usage);
let release!: () => void;
let started!: () => void;
const waiting = new Promise<void>((resolve) => { release = resolve; });
const entered = new Promise<void>((resolve) => { started = resolve; });
const { engine, histories } = scripted([async () => {
started();
await waiting;
return fauxAssistantMessage("Replacement response.");
}]);
const retry = turn(engine, chat.id, { type: "retry" });
await entered;
try {
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before, "write-ahead must not delete the failed attempt");
assert.deepEqual(chatStore.getChat(chat.id)!.turns, [usage]);
assert.doesNotMatch(histories.at(-1)!, /partial explanation/);
} finally {
release();
await retry;
}
const saved = chatStore.getChat(chat.id)!;
assertPreserved(before, saved.messages);
assert.ok(saved.turns?.some((entry) => entry.id === usage.id && entry.messageId === "partial"));
assert.equal(saved.messages.length, before.length + 1, "the persistence offset counts the temporary prefix, not the full saved record");
});
it("keeps every failed attempt across repeated retries, then appends success once", async () => {
const { engine, contexts } = scripted([networkFailure, networkFailure, () => fauxAssistantMessage("Recovered.")]);
const chat = createConfiguredChat("Test: repeated model failure");
await turn(engine, chat.id, { type: "text", text: "hello" });
const first = chatStore.getChat(chat.id)!.messages;
await turn(engine, chat.id, { type: "retry" });
const second = chatStore.getChat(chat.id)!.messages;
assertPreserved(first, second);
assert.equal(second.length, first.length + 1);
assert.ok(endsInFailedTurn(second));
await turn(engine, chat.id, { type: "retry" });
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(second, saved);
assert.equal(saved.length, second.length + 1);
assert.deepEqual(contexts, [["user"], ["user"], ["user"]]);
assert.equal(new SessionModel({ chatId: chat.id, initialMessages: saved }).getState().error, null);
});
it("preserves a trailing plan block while retrying from the preceding input", async () => {
const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Planned reply.")]);
const chat = createConfiguredChat("Test: retry past a plan block");
await turn(engine, chat.id, { type: "text", text: "hello" });
const before = [...chatStore.getChat(chat.id)!.messages, newPlanBlockMessage("plan-1", {})];
chatStore.saveMessages(chat.id, before);
assert.ok(endsInFailedTurn(before));
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, saved);
assert.deepEqual(roles(chat.id), ["user", "assistant", "custom", "assistant"]);
assert.deepEqual(contexts.at(-1), ["user"], "the plan block remains UI-only");
});
it("does not reexecute completed tools before a later model failure", async () => {
let executions = 0;
const { engine, contexts } = scripted([
() => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "note-1" })], { stopReason: "toolUse" }),
networkFailure,
() => fauxAssistantMessage("The saved note is ready."),
]);
const tools: AgentBuildOptions["tools"] = {
save_note: {
name: "save_note", label: "save_note", description: "Record one fixture side effect.", parameters: Type.Object({}),
execute: async () => {
executions++;
return { content: [{ type: "text", text: "Note saved" }], details: { saved: true } };
},
},
};
const factory: AgentFactory = (opts) => createTeacherAgent(undefined, { ...opts, engine, tools });
const chat = createConfiguredChat("Test: no completed tool replay");
await turn(engine, chat.id, { type: "text", text: "save a note" }, factory);
assert.equal(executions, 1);
const before = chatStore.getChat(chat.id)!.messages;
assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]);
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assert.equal(executions, 1, "Retry requests another response, not execution of stored calls");
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]);
});
it("never executes tool calls carried by a failed partial response", async () => {
let executions = 0;
const { engine, contexts } = scripted([
() => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "unexecuted" })], { stopReason: "error", errorMessage: "cut off" }),
() => fauxAssistantMessage("Recovered without running the partial call."),
]);
const factory: AgentFactory = (opts) => createTeacherAgent(undefined, {
...opts, engine,
tools: {
save_note: {
name: "save_note", label: "save_note", description: "Must not execute.", parameters: Type.Object({}),
execute: async () => { executions++; return { content: [], details: {} }; },
},
},
});
const chat = createConfiguredChat("Test: unexecuted partial tool call");
await turn(engine, chat.id, { type: "text", text: "hello" }, factory);
assert.equal(executions, 0);
const before = chatStore.getChat(chat.id)!.messages;
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assert.equal(executions, 0);
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
assert.deepEqual(contexts.at(-1), ["user"]);
});
it("a retained failed probe retries with the prober, concludes once, then selects the teacher", async () => {
const input = { assessment: { strands: [{ strand: "planet order", assessment: "Names known; ordering needs work." }] } };
const { engine, histories } = scripted([
() => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "failed-probe" })], { stopReason: "error", errorMessage: "cut off" }),
() => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "fresh-probe" })], { stopReason: "toolUse" }),
() => fauxAssistantMessage("Assessment ready."),
() => fauxAssistantMessage("Now planning the lesson."),
]);
const chat = createConfiguredChat("Test: failed probe recovery");
const delivered: SteerMessage[] = [];
const selections: boolean[] = [];
const factory: AgentFactory = (opts) => {
selections.push(opts.teaching);
if (opts.teaching) return createTeacherAgent(undefined, { ...opts, engine });
return createProberAgent({
chatId: chat.id, chatStore, jobStore, deliver: (s) => delivered.push(s), spawn: () => {},
}, { ...opts, engine });
};
await turn(engine, chat.id, { type: "text", text: "teach me planet order" }, factory);
const before = chatStore.getChat(chat.id)!.messages;
assert.ok(endsInFailedTurn(before));
assert.equal(hasProbeDone(before), false);
assert.equal(phaseAt3Lh(before), "probe");
assert.equal(delivered.length, 0, "the stored failed call never ran");
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
const recovered = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, recovered);
assert.deepEqual(selections, [false, false], "Retry must still build the prober");
assert.equal(hasProbeDone(recovered), true);
assert.equal(phaseAt3Lh(recovered), "plan");
assert.equal(delivered.length, 1, "the full retained-store guard accepts the fresh conclusion");
assert.deepEqual(recovered.filter((m) => m.role === "toolResult").map((m) => m.role === "toolResult" && m.toolCallId), ["fresh-probe"]);
assert.doesNotMatch(histories[1]!, /failed-probe/, "the failed call is omitted only from temporary Retry history");
assert.equal((await turn(engine, chat.id, { type: "text", text: "go on" }, factory)).status, 200);
assert.deepEqual(selections, [false, false, true]);
assert.equal(delivered.length, 1, "later turns do not replay either saved call");
assertPreserved(recovered, chatStore.getChat(chat.id)!.messages);
});
for (const kind of ["prober", "teacher", "practice"] as const) {
it(`uses the same retained-history retry boundary for the ${kind}`, async () => {
const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]);
const chat = createConfiguredChat(`Test: ${kind} retry`, kind === "practice" ? "practice" : undefined);
const before: LhMessage[] = [newUserMessage("hello")];
if (kind === "teacher") {
before.push({ ...fauxAssistantMessage([fauxToolCall("probe_done", {}, { id: "probe-1" })], { stopReason: "toolUse" }), id: "probe-response" });
before.push({ id: "probe-result", role: "toolResult", toolCallId: "probe-1", toolName: "probe_done", content: [], details: { ok: true }, isError: false, timestamp: 1 });
}
before.push(failed("failed-response", "Partial reply."));
chatStore.saveMessages(chat.id, before);
const factory: AgentFactory = (opts) => {
assert.equal(opts.teaching, kind === "teacher");
assert.ok(opts.messages?.every((m) => m.role !== "assistant" || m.stopReason !== "error"));
if (kind === "practice") return createPracticeAgent({ chatId: chat.id, chatStore }, { ...opts, engine });
if (kind === "prober") return createProberAgent({ chatId: chat.id, chatStore, jobStore, deliver: () => {}, spawn: () => {} }, { ...opts, engine });
return createTeacherAgent(undefined, { ...opts, engine });
};
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
assert.equal(chatStore.getChat(chat.id)!.messages.length, before.length + 1);
assert.deepEqual(contexts.at(-1), kind === "teacher" ? ["user", "assistant", "toolResult"] : ["user"]);
});
}
it("treats an explicit Retry of an automatic main-agent response as foreground", async () => {
const { engine } = scripted([() => fauxAssistantMessage("Explicitly requested response.")]);
const chat = createConfiguredChat("Test: foreground manual retry");
const before = [newUserMessage("hello"), newHandoffMessage(chat.id), failed("automatic-failure")];
chatStore.saveMessages(chat.id, before);
assert.equal(reconstructRequestCause(before), "background");
let built = false;
const factory: AgentFactory = (opts) => {
built = true;
assert.equal(opts.callPurpose?.background, false);
return createTeacherAgent(undefined, { ...opts, engine });
};
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assert.ok(built);
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
});
});
describe("retained generations and Pi's native provider filtering", () => {
for (const stopReason of ["error", "aborted"] as const) {
it(`does not fabricate results for ${stopReason} partial tool calls on later requests`, () => {
const faux = createFauxCore({});
const partial: LhMessage = {
...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "partial-call" })], { stopReason }), id: "partial",
};
const recovered: LhMessage = { ...fauxAssistantMessage("Recovered."), id: "recovered" };
const messages = [newUserMessage("hello"), partial, recovered, newUserMessage("next question")];
const before = JSON.parse(JSON.stringify(messages));
const converted = toLlmMessages(asAgentMessages(messages));
assert.ok(converted.some((m) => m.role === "assistant" && m.stopReason === stopReason), "the app still leaves failed-generation filtering to Pi");
assert.equal(converted.filter((m) => m.role === "toolResult").length, 0, "no orphan synthetic result for an unexecuted partial call");
const providerHistory = transformMessages(converted, faux.getModel());
assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "user"]);
assert.doesNotMatch(JSON.stringify(providerHistory), /partial-call/);
assert.deepEqual(messages, before, "model conversion never changes the saved attempts");
});
}
it("keeps an actual tool-cancellation result and its completed calling message", () => {
const faux = createFauxCore({});
const call: LhMessage = {
...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "completed-call" })], { stopReason: "toolUse" }), id: "caller",
};
const cancellation: LhMessage = {
id: "cancelled-result", role: "toolResult", toolCallId: "completed-call", toolName: "save_note",
content: [{ type: "text", text: "Tool execution was aborted." }], isError: true, timestamp: 1,
};
const messages = [newUserMessage("hello"), call, cancellation, newUserMessage("continue")];
const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel());
assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]);
const result = providerHistory.find((m) => m.role === "toolResult");
assert.ok(result?.role === "toolResult");
assert.equal(result.toolCallId, "completed-call");
assert.equal(result.isError, true);
assert.deepEqual(result.content, cancellation.content);
});
it("still repairs an unanswered call from a completed generation", () => {
const faux = createFauxCore({});
const question: LhMessage = {
...fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "unanswered-question" })], { stopReason: "toolUse" }), id: "question",
};
const messages = [newUserMessage("hello"), question, newUserMessage("move on")];
const before = JSON.parse(JSON.stringify(messages));
const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel());
assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]);
const result = providerHistory.find((m) => m.role === "toolResult");
assert.ok(result?.role === "toolResult");
assert.equal(result.toolCallId, "unanswered-question");
assert.deepEqual(result.content, [{ type: "text", text: INTERRUPTED_TEXT }]);
assert.deepEqual(messages, before, "the repair is temporary, never persisted");
});
});
describe("Retry refusal and revalidation", () => {
it("rejects an invalid Retry before provider gates, turn claims or writes", async () => {
const chat = chatStore.createChat("Test: retry refused before subscription refresh");
chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "not-a-fixture-model" } });
const before = chatStore.getChat(chat.id)!;
rememberTurnFailure(chat.id, "Existing settlement notice");
const events: string[] = [];
const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); });
try {
const res = await handleChatRequest(post(chat.id, { type: "retry" }), () => { throw new Error("must not construct an agent"); });
assert.equal(res.status, 400, "invalid retry wins even with an unavailable subscription model");
assert.match(await res.text(), /requires a failed turn/);
assert.deepEqual(chatStore.getChat(chat.id), before);
assert.equal(turnFailure(chat.id), "Existing settlement notice", "beginTurn did not clear recovery state");
assert.equal(isTurnRunning(chat.id), false);
assert.deepEqual(events, []);
} finally {
unsubscribe();
clearTurnFailure(chat.id);
}
});
it("rejects Retry after later compaction without removing the error or its boundary", async () => {
const { engine, contexts } = scripted([]);
const chat = createConfiguredChat("Test: compacted failure");
const failure = failed("partial", "Partial explanation.");
const marker = compaction(failure.id);
const before = [newUserMessage("hello"), failure, marker];
chatStore.saveMessages(chat.id, before);
const snapshot = chatStore.getChat(chat.id)!;
const res = await turn(engine, chat.id, { type: "retry" });
assert.equal(res.status, 400);
const body = await res.json() as { error: string };
assert.equal(body.error, "fetch failed\n\nThis response cannot be retried after compaction. Send a new message to continue.");
assert.deepEqual(chatStore.getChat(chat.id), snapshot);
assert.equal(contexts.length, 0);
assert.equal(new SessionModel({ chatId: chat.id, initialMessages: before }).getState().error?.message, "fetch failed");
assert.equal(endsInFailedTurn(before), false);
});
for (const later of ["compaction", "learner-input"] as const) {
it(`revalidates against ${later} landing on the post-compaction reread`, async () => {
const { engine, contexts } = scripted([]);
const chat = createConfiguredChat(`Test: retry race with ${later}`);
const before = [newUserMessage("hello"), failed("failed-response")];
chatStore.saveMessages(chat.id, before);
const appended = later === "compaction" ? compaction("failed-response") : newUserMessage("A newer input");
const read = chatStore.getChat;
let reads = 0;
// Simulate another writer landing during the awaited gate. Do not
// change or invoke the compaction implementation to exercise Retry.
const reader = mock.method(chatStore, "getChat", (id: string) => {
if (id === chat.id && ++reads === 2) chatStore.saveMessages(id, [...before, appended]);
return read(id);
});
const events: string[] = [];
const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); });
try {
const res = await turn(engine, chat.id, { type: "retry" });
assert.equal(res.status, 400);
if (later === "compaction") assert.match(await res.text(), /fetch failed.*cannot be retried after compaction/);
assert.deepEqual(read(chat.id)!.messages, [...before, appended]);
assert.equal(contexts.length, 0);
assert.equal(isTurnRunning(chat.id), false);
assert.deepEqual(events, [], "no turn was claimed after final validation refused");
} finally {
reader.mock.restore();
unsubscribe();
}
});
}
it("a sibling's successful retry makes a later stale Retry refuse without changing history", async () => {
const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Recovered.")]);
const chat = createConfiguredChat("Test: stale sibling Retry");
await turn(engine, chat.id, { type: "text", text: "hello" });
await turn(engine, chat.id, { type: "retry" });
const before = chatStore.getChat(chat.id)!;
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 400);
assert.deepEqual(chatStore.getChat(chat.id), before);
assert.equal(contexts.length, 2);
});
});
describe("the Retry button's client path", () => {
it("keeps the original model error plus the explanation when compaction wins the click race", async () => {
const messages = [newUserMessage("hello"), failed("failed-response")];
const compacted = [...messages, compaction("failed-response")];
const explanation = "This response cannot be retried after compaction. Send a new message to continue.";
const refused = `fetch failed\n\n${explanation}`;
const m = new SessionModel({
chatId: "chat_x", initialMessages: messages,
fetch: async (_url, init) => {
assert.equal(init?.method, "POST");
assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" });
m.adoptRecord(compacted);
return Response.json({ error: refused }, { status: 400 });
},
});
await m.send({ type: "retry" });
assert.equal(m.getState().status, "error");
assert.equal(m.getState().error!.message, refused, "the banner keeps both messages as readable text, without JSON syntax");
assert.deepEqual(m.getState().nativeMessages, compacted);
assert.equal(endsInFailedTurn(m.getState().nativeMessages), false);
});
for (const { status, body, expected } of [
{ status: 400, body: '{"error":"retry input requires a failed turn to retry"}', expected: "retry input requires a failed turn to retry" },
{ status: 400, body: "Plain refusal", expected: "Plain refusal" },
{ status: 400, body: '{"reason":"not an error envelope"}', expected: '{"reason":"not an error envelope"}' },
{ status: 503, body: '{"error":"model gate","code":"models_not_configured"}', expected: '{"error":"model gate","code":"models_not_configured"}' },
]) {
it(`preserves the appropriate Retry refusal format for ${status}: ${body}`, async () => {
const m = new SessionModel({
chatId: "chat_x", initialMessages: [newUserMessage("hello"), failed("failed-response")],
fetch: async () => new Response(body, { status }),
});
await m.send({ type: "retry" });
assert.equal(m.getState().error?.message, expected);
});
}
it("after a dropped request, Retry posts straight from the error status", async () => {
const failedTurn = [newUserMessage("hello"), failed("a1")];
const posted: string[] = [];
let online = false;
const fetch = (async (_url: string, init?: RequestInit) => {
if (!online) throw new TypeError("Failed to fetch");
if (init?.method === "POST") posted.push(String(init.body));
return new Response("{}", { status: 500 });
}) as typeof globalThis.fetch;
const m = new SessionModel({ chatId: "chat_x", initialMessages: failedTurn, fetch });
await m.send({ type: "retry" });
assert.equal(m.getState().status, "error");
online = true;
await m.send({ type: "retry" });
assert.equal(posted.length, 1, "send() recovers from error without stop()");
assert.deepEqual(JSON.parse(posted[0]!).input, { type: "retry" });
assert.deepEqual(m.getState().nativeMessages, failedTurn, "there is no optimistic learner message or deletion");
});
});
describe("Retry eligibility and temporary omissions (pure)", () => {
it("parses Retry without appending input", () => {
const input = parseChatInput({ type: "retry" });
assert.deepEqual(input, { type: "retry" });
assert.deepEqual(inputAppends({ messages: [newUserMessage("hi"), failed("a1")] }, input), []);
});
it("omits consecutive failures back to their input without mutating the record", () => {
const messages = [newUserMessage("hi"), failed("a1"), failed("a2")];
const before = JSON.parse(JSON.stringify(messages));
assert.deepEqual([...retryDrops(messages)].sort(), ["a1", "a2"]);
assert.deepEqual(messages, before);
});
it("looks past plan blocks, but compaction after the latest failure blocks Retry", () => {
const user = newUserMessage("hi");
const failure = failed("a1");
const block = newPlanBlockMessage("plan-1", {});
const marker = compaction(user.id);
assert.deepEqual([...retryDrops([user, failure, block])], ["a1"]);
assert.equal(endsInFailedTurn([user, block]), false);
for (const messages of [[user, failure, marker], [user, failure, marker, block], [user, failure, block, marker]]) {
assert.equal(endsInFailedTurn(messages), false);
assert.equal(failedResponseAtEnd(messages)?.id, failure.id, "only the Retry button disappears, not the banner");
assert.throws(() => retryDrops(messages), /fetch failed[\s\S]*cannot be retried after compaction/);
}
assert.deepEqual([...retryDrops([user, failed("old"), marker, failed("new")])], ["new"], "a failure after compaction can retry its already-compacted starting state");
});
it("excludes aborts, healthy responses, missing input and assistant-only predecessors", () => {
const user = newUserMessage("hi");
const aborted = { ...failed("aborted"), stopReason: "aborted" } as LhMessage;
const healthy = { ...fauxAssistantMessage("Done"), id: "healthy" } as LhMessage;
for (const messages of [[user], [failed("a1")], [user, aborted], [user, healthy], [healthy, failed("a1")]]) {
assert.equal(endsInFailedTurn(messages), false);
assert.throws(() => retryDrops(messages), ChatInputError);
}
assert.equal(failedResponseAtEnd([user, failed("old"), aborted]), undefined);
});
});
+1 -1
View File
@@ -110,7 +110,7 @@ test("render-settings: its write stamps the family sidecar too", () => {
// ── the fixtures stay the legacy pin: golden data reads unstamped ───────────
test("golden fixtures are deliberately the unstamped legacy shape (lazy v1 = current-valid)", () => {
const here = path.dirname(new URL(import.meta.url).pathname);
const here = import.meta.dirname;
const teach = JSON.parse(fs.readFileSync(path.join(here, "fixtures", "golden-data", "chats", "chat_mv27rrra_feff23b6.json"), "utf8"));
assert.equal(teach.version, undefined, "fixture records pin the unstamped read path");
assert.ok(!fs.existsSync(path.join(here, "fixtures", "golden-data", "graph", "format.json")), "no sidecar in the fixture graph dir");
+373
View File
@@ -0,0 +1,373 @@
/** Explicit Claude output: closed-network synthetic fixtures only. Native
* history stays native; UI, extraction, and Markdown read the same prose. */
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
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 { Value } from "typebox/value";
import {
createFauxCore, fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall,
type FauxResponseStep,
} from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import type { AgentBuildOptions } from "../src/agent/teacher-agent.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
// Set the data/auth seams BEFORE importing app modules. Do not read local
// keys, recordings, or learner sessions, even if a dev server is running.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-send-to-user-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
process.env = {
...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}),
...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}),
NODE_ENV: "test",
LH_DATA_DIR: path.join(scratch, "data"),
LH_AUTH_FILE: path.join(scratch, "auth.json"),
HOME: path.join(scratch, "home"),
};
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); };
after(() => {
process.env = originalEnv;
globalThis.fetch = originalFetch;
fs.rmSync(scratch, { recursive: true, force: true });
});
const { learningLanguageInstruction } = await import("../src/core/i18n.ts");
const { sendToUserTool } = await import("../src/agent/tools/send-to-user.ts");
const { createProberAgent, createTeacherAgent, createPracticeAgent, composeInstructions } = await import("../src/agent/teacher-agent.ts");
const { SEND_TO_USER_TOOL, activeProblemOf, asAgentMessages, newUserMessage, sendToUserText, toLlmMessages, userFacingTextOf } = await import("../src/core/native-messages.ts");
const { coverageOf, messageFingerprint, messageViewLines, teacherProseBlocks } = await import("../src/core/coverage.ts");
const { projectView } = await import("../src/core/view.ts");
const { sessionMarkdown } = await import("../src/core/session-markdown.ts");
const { transcriptView } = await import("../src/agent/subagents/extraction.ts");
const { runSubagent } = await import("../src/agent/subagents/pi-run.ts");
const { persistTurn } = await import("../src/server/turn-service.ts");
const { runPiTurn } = await import("../src/server/pi/ui-stream.ts");
const { SessionModel } = await import("../components/session-core.ts");
const { chatStore } = await import("../src/store/chats.ts");
const { jobStore } = await import("../src/store/jobs.ts");
function scripted(provider: string, id: string, steps: FauxResponseStep[] = []) {
const faux = createFauxCore({ provider, models: [{ id }] });
faux.setResponses(steps);
const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple };
return { engine, faux };
}
const chatId = chatStore.createChat("Synthetic output gate").id;
const factories = {
prober: (opts: AgentBuildOptions) => createProberAgent({ chatId, chatStore, jobStore, deliver: () => {}, spawn: () => {} }, opts),
teacher: (opts: AgentBuildOptions) => createTeacherAgent({ chatId, chatStore, jobStore, spawn: () => {} }, opts),
practice: (opts: AgentBuildOptions) => createPracticeAgent({ chatId, chatStore }, opts),
};
function outputCall(message: string | number | null = "An explanation.", id = "send-1") {
return { type: "toolCall" as const, id, name: SEND_TO_USER_TOOL, arguments: { message } };
}
function assistant(content: unknown[], id = "a1"): LhMessage {
return { ...fauxAssistantMessage(""), content, id } as LhMessage;
}
function ack(callId = "send-1", isError = false): LhMessage {
return {
id: `result-${callId}`, role: "toolResult", toolCallId: callId, toolName: SEND_TO_USER_TOOL,
content: [{ type: "text", text: isError ? "Invalid tool input" : "Message received for display." }],
details: null, isError, timestamp: 1,
};
}
function prose(messages: ReturnType<typeof projectView>): string[] {
return messages.filter((m) => m.role === "assistant").flatMap((m) => m.parts.filter((p) => p.type === "text").map((p) => p.text!));
}
describe("send_to_user model gate", () => {
for (const [provider, id, enabled] of [
["anthropic", "claude-opus-5-5", true],
["anthropic", "claude-haiku-4-5-20251001", true],
["openrouter", "anthropic/claude-opus-5.5", true],
["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],
] as const) {
it(`${provider}/${id}: ${enabled ? "includes" : "omits"} tool and description in all conversation agents`, () => {
const { engine } = scripted(provider, id);
for (const [kind, factory] of Object.entries(factories)) {
const agent = factory({ engine });
const output = agent.state.tools.find((tool) => tool.name === SEND_TO_USER_TOOL);
assert.equal(Boolean(output), enabled, kind);
if (enabled) {
assert.equal(output!.description, sendToUserTool.description);
assert.equal(typeof output!.prepareArguments, "function", "standard argument healing is wired");
}
assert.equal(
agent.state.systemPrompt,
`${composeInstructions(kind as keyof typeof factories)}\n\n${learningLanguageInstruction("en")}`,
);
assert.ok(!agent.state.systemPrompt.includes(SEND_TO_USER_TOOL), "no added prompt instructions");
}
});
}
it("explicitly injected teacher/practice test registries remain unchanged", () => {
const { engine } = scripted("anthropic", "claude-opus-5-5");
for (const factory of [factories.teacher, factories.practice]) {
assert.deepEqual(factory({ engine, tools: {} }).state.tools, []);
}
});
it("background agents do not acquire a learner-output tool", async () => {
let calls = 0;
const { engine } = scripted("anthropic", "claude-opus-5-5", [(context) => {
calls++;
assert.ok(!context.messages.some((m) => m.role === "system" && m.toolsAdded?.some((t) => t.name === SEND_TO_USER_TOOL)));
return fauxAssistantMessage("Synthetic background result.");
}]);
await runSubagent([], { role: "researcher", engine, systemPrompt: "Synthetic fixture.", prompt: "Finish.", maxTurns: 2 });
assert.equal(calls, 1);
});
});
describe("send_to_user mechanics", () => {
it("requires nonblank text and acknowledges without echoing or terminating", async () => {
assert.equal(sendToUserTool.name, SEND_TO_USER_TOOL);
for (const input of [{}, { message: "" }, { message: " \n\t" }, { message: 3 }, { message: null }]) {
assert.equal(Value.Check(sendToUserTool.parameters, input), false);
}
const message = "**A full explanation**\n\n$$a = b$$";
assert.equal(Value.Check(sendToUserTool.parameters, { message }), true);
const result = await sendToUserTool.execute("send-1", { message });
assert.ok(!result.isError);
assert.ok(!result.terminate);
assert.ok(!JSON.stringify(result).includes(message));
assert.deepEqual(result.content, [{ type: "text", text: "Message received for display." }]);
});
it("only extracts the output tool's nonblank message, without modifying it", () => {
const message = " **Keep spacing and math** $x$\n";
assert.equal(sendToUserText(outputCall(message)), message);
for (const block of [null, {}, { type: "text", text: "ordinary" }, outputCall(2), outputCall(" "), { ...outputCall(), name: "other" }]) {
assert.equal(sendToUserText(block), undefined);
}
assert.deepEqual(userFacingTextOf([null, fauxThinking("Not lesson prose."), fauxText("First"), outputCall(message), fauxToolCall("quiz", { question: "Q?" })]), ["First", message]);
});
});
describe("send_to_user transcript projection", () => {
it("renders in place as prose, with thinking unchanged and no tool/ack duplication", () => {
const messages = [assistant([
fauxText("Before."), outputCall("**The lesson.** $x = 1$"),
fauxThinking("Private reasoning."), fauxToolCall("quiz", { question: "Q?" }, { id: "quiz-1" }),
fauxText("After."),
]), ack()];
const before = structuredClone(messages);
const view = projectView(messages);
assert.deepEqual(view[0]!.parts.map((p) => p.type), ["step-start", "reasoning", "text", "text", "tool-quiz", "text"]);
assert.deepEqual(prose(view), ["Before.", "**The lesson.** $x = 1$", "After."]);
assert.equal(view[0]!.parts.find((p) => p.type === "reasoning")!.text, "Private reasoning.");
assert.deepEqual(view[0]!.parts.filter((p) => p.type === "text").map((p) => p.id), ["a1-t0", "a1-t1", "a1-t2"]);
assert.deepEqual(messages, before, "projection never rewrites native content");
assert.deepEqual(projectView(JSON.parse(JSON.stringify(messages))), view, "reload is identical, without consulting a provider gate");
});
it("streams available arguments as replacing prose, never as a live tool card", () => {
const live = { inFlight: true, streamingIds: new Set(["a1"]), executions: new Map() };
for (const text of ["A", "A growing", "A growing explanation."]) {
const view = projectView([assistant([outputCall(text)])], live);
assert.deepEqual(prose(view), [text]);
assert.ok(!view[0]!.parts.some((p) => p.type.startsWith("tool-")));
}
const empty = projectView([assistant([{ ...outputCall(), arguments: {} }])], live);
assert.deepEqual(prose(empty), []);
assert.ok(!empty[0]!.parts.some((p) => p.type.startsWith("tool-")));
});
it("malformed messages contain no prose; failures keep the existing error path live and after reload", () => {
assert.deepEqual(prose(projectView([assistant([outputCall(3)])])), []);
const messages = [assistant([outputCall("Incomplete teaching.")]), ack("send-1", true)];
const settled = projectView(messages);
assert.deepEqual(prose(settled), []);
assert.equal(settled[0]!.parts[1]!.type, "tool-send_to_user");
assert.equal(settled[0]!.parts[1]!.state, "output-error");
const live = projectView(messages.slice(0, 1), {
inFlight: true, streamingIds: new Set(["a1"]),
executions: new Map([["send-1", { isError: true, errorText: "Invalid tool input" }]]),
});
assert.deepEqual(prose(live), []);
assert.equal(live[0]!.parts[1]!.state, "output-error");
});
});
describe("send_to_user extraction and Markdown", () => {
it("selects output prose once and in order, preserving old fingerprints", () => {
const message = assistant([fauxText("First."), outputCall("Second."), fauxThinking("Hidden."), outputCall("Third.", "send-2")]);
const messages = [message, ack(), ack("send-2")];
assert.deepEqual(messageViewLines(message, "a1"), ["TEACHER [a1] First.", "TEACHER [a1] Second.", "TEACHER [a1] Third."]);
assert.deepEqual(teacherProseBlocks(messages), ["First.", "Second.", "Third."]);
assert.equal(coverageOf(messages, [], "fixture").uncoveredCount, 1);
const md = sessionMarkdown(messages, { anchors: false });
assert.equal(md, "### Teacher\n\nFirst.\n\n### Teacher\n\nSecond.\n\n### Teacher\n\nThird.");
const record = chatStore.createChat("Synthetic extraction");
assert.equal(transcriptView({ ...record, messages }), "# Synthetic extraction\n\nTEACHER [a1] First.\n\nTEACHER [a1] Second.\n\nTEACHER [a1] Third.");
const legacy = assistant([fauxText("Old text."), fauxThinking("Old thinking.")], "legacy");
const oldFingerprint = createHash("sha256").update(JSON.stringify(["assistant", ["TEACHER [legacy] Old text."]])).digest("hex");
assert.equal(messageFingerprint(legacy), oldFingerprint, "no re-extraction/migration of legacy prose");
});
it("does not extract/export failed output calls, including partial calls from truncated responses", () => {
const message = assistant([fauxText("Keep this."), outputCall("Do not treat this as delivered.")]);
const messages = [message, ack("send-1", true)];
assert.deepEqual(teacherProseBlocks(messages), ["Keep this."]);
assert.equal(sessionMarkdown(messages, { anchors: false }), "### Teacher\n\nKeep this.");
const record = chatStore.createChat("Failed output fixture");
assert.ok(!transcriptView({ ...record, messages }).includes("Do not treat"));
const coverage = coverageOf([assistant([outputCall()]), ack("send-1", true)], [], "fixture");
assert.equal(coverage.uncoveredCount, 0);
assert.equal(coverage.uncoveredTokens, 0);
});
});
describe("send_to_user interrupted generations", () => {
for (const stopReason of ["error", "aborted"] as const) {
it(`${stopReason} without a tool result never becomes delivered teaching; ordinary text stays unchanged`, () => {
const message = { ...assistant([fauxText("Keep ordinary text."), outputCall("Unfinished output.")]), stopReason };
const messages = [message];
assert.deepEqual(prose(projectView(messages)), ["Keep ordinary text."]);
assert.deepEqual(prose(projectView(JSON.parse(JSON.stringify(messages)))), ["Keep ordinary text."]);
assert.deepEqual(messageViewLines(message, message.id), ["TEACHER [a1] Keep ordinary text."]);
assert.deepEqual(teacherProseBlocks(messages), ["Keep ordinary text."]);
assert.equal(sessionMarkdown(messages, { anchors: false }), "### Teacher\n\nKeep ordinary text.");
const onlyOutput = { ...assistant([outputCall("Unfinished output.")]), stopReason };
assert.equal(coverageOf([onlyOutput], [], "fixture").uncoveredCount, 0);
});
}
});
describe("send_to_user native flow (offline)", () => {
it("output alone continues the loop, so the model receives its native acknowledgement", async () => {
const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [
fauxAssistantMessage([outputCall("Read this first.")], { stopReason: "toolUse" }),
(context) => {
assert.ok(context.messages.some((m) => m.role === "toolResult" && m.toolName === SEND_TO_USER_TOOL));
return fauxAssistantMessage([fauxText("Final plain reply.")]);
},
]);
const agent = createTeacherAgent(undefined, { engine });
await agent.prompt("Synthetic request.");
assert.equal(faux.state.callCount, 2);
});
for (const outputFirst of [true, false]) {
it(`output + practice problem stops after the pose (${outputFirst ? "output first" : "problem first"})`, async () => {
const output = outputCall("Try this practice problem.");
const problem = fauxToolCall("problem", {
statement: "What is two plus two?", mode: "recall", modelSolution: "Four.",
}, { id: "problem-1" });
const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [
fauxAssistantMessage(outputFirst ? [output, problem] : [problem, output], { stopReason: "toolUse" }),
fauxAssistantMessage("Premature continuation must not run."),
]);
const agent = createPracticeAgent({ chatId, chatStore }, { engine });
await agent.prompt("Pose a synthetic problem.");
assert.equal(faux.state.callCount, 1, "wait for the learner after a successful mixed-batch pose");
const results = agent.state.messages.filter((m) => m.role === "toolResult");
assert.equal(results.length, 2);
assert.ok(results.every((m) => !m.isError));
});
}
for (const stopReason of ["error", "aborted"] as const) {
it(`${stopReason} mixed output/problem generation never executes or activates the pose`, async () => {
const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [
fauxAssistantMessage([
outputCall("Undelivered introduction."),
fauxToolCall("problem", {
statement: "What is two plus two?", mode: "recall", modelSolution: "Four.",
}, { id: "unfinished-problem" }),
], { stopReason }),
]);
const agent = createPracticeAgent({ chatId, chatStore }, { engine });
await agent.prompt("Pose a synthetic problem.");
assert.equal(faux.state.callCount, 1);
assert.ok(!agent.state.messages.some((m) => m.role === "toolResult"));
const messages = agent.state.messages.map((m, i) => ({ ...m, id: `interrupted-${i}` })) as LhMessage[];
assert.equal(activeProblemOf(messages), null);
assert.deepEqual(prose(projectView(messages)), []);
});
}
it("a rejected mixed-batch practice pose retries, then stops after the successful pose", async () => {
const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [
fauxAssistantMessage([
outputCall("Try this practice problem."),
fauxToolCall("problem", { statement: "Incomplete problem.", mode: "recall" }, { id: "bad-problem" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([
outputCall("Here is the corrected problem.", "send-2"),
fauxToolCall("problem", {
statement: "What is two plus two?", mode: "recall", modelSolution: "Four.",
}, { id: "good-problem" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage("Premature continuation must not run."),
]);
const agent = createPracticeAgent({ chatId, chatStore }, { engine });
await agent.prompt("Pose a synthetic problem.");
assert.equal(faux.state.callCount, 2, "failed authoring retries; successful pose waits for the learner");
const results = agent.state.messages.filter((m) => m.role === "toolResult" && m.toolName === "problem");
assert.equal(results.length, 2);
assert.ok(results[0]?.role === "toolResult" && results[0].isError);
assert.ok(results[1]?.role === "toolResult" && !results[1].isError);
});
it("output + question streams, persists natively, replays, and reloads as prose before the question", async () => {
const lesson = "**Synthetic lesson:** four is twice two. $2 + 2 = 4$";
const quiz = {
type: "check", nodeId: "synthetic", question: "What is two plus two?",
options: [{ label: "Four", value: "4" }, { label: "Five", value: "5" }],
correctAnswer: "4", explanation: "Add two pairs.",
};
const { engine, faux } = scripted("openrouter", "anthropic/claude-opus-5.5", [
fauxAssistantMessage([
fauxThinking("Synthetic private thought."), outputCall(lesson),
fauxToolCall("quiz", quiz, { id: "quiz-1" }),
], { stopReason: "toolUse" }),
]);
const chat = chatStore.createChat("Synthetic native output flow");
const user = newUserMessage("Explain synthetic arithmetic.");
chatStore.saveMessages(chat.id, [user]);
const agent = createTeacherAgent(undefined, { engine, messages: asAgentMessages([user]) });
const handle = runPiTurn({
agent,
updateFlushMs: 0,
onFinish: (messages, aborted) => { persistTurn(chatStore, chat.id, 1, messages, aborted); },
});
const wire = await new Response(handle.subscribe()).text();
assert.ok(wire.endsWith("data: [DONE]\n\n"));
assert.equal(faux.state.callCount, 1, "question stops the mixed batch as before");
const saved = chatStore.getChat(chat.id)!.messages;
const response = saved.find((m) => m.role === "assistant")!;
assert.equal(response.role, "assistant");
if (response.role !== "assistant") assert.fail("missing assistant");
assert.deepEqual(response.content.map((c) => c.type), ["thinking", "toolCall", "toolCall"]);
assert.deepEqual(response.content[1], outputCall(lesson));
assert.ok(saved.some((m) => m.role === "toolResult" && m.toolName === SEND_TO_USER_TOOL), "native output ack persists");
assert.ok(!saved.some((m) => m.role === "toolResult" && m.toolName === "quiz"), "question placeholder remains transient");
const providerHistory = toLlmMessages(asAgentMessages(saved));
assert.deepEqual(providerHistory.find((m) => m.role === "assistant")!.content, response.content, "next request keeps native call/thinking structure");
assert.deepEqual(prose(projectView(saved)), [lesson]);
const parts = projectView(saved).flatMap((m) => m.parts);
assert.ok(parts.findIndex((p) => p.type === "text") < parts.findIndex((p) => p.type === "tool-quiz"));
const client = new SessionModel({ chatId: chat.id, initialMessages: [user], fetch: globalThis.fetch });
await client.consumeStream(new Response(wire));
assert.deepEqual(prose(client.getState().messages), [lesson], "native event replay shows the lesson exactly once");
assert.deepEqual(prose(projectView(JSON.parse(JSON.stringify(saved)))), [lesson], "reload preserves the lesson");
assert.deepEqual(teacherProseBlocks(saved), [lesson]);
assert.equal(sessionMarkdown(saved).split(lesson).length - 1, 1);
});
});
+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);
});
+460 -7
View File
@@ -24,8 +24,8 @@ import { afterEach, describe, it } from "node:test";
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
import type { ChatInput } from "../src/core/input-events.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts";
import { newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts";
import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal, pendingQuestionOf } from "../src/core/native-messages.ts";
import { endsInFailedTurn, newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts";
import { dismissedAnswer } from "../src/core/question-core.ts";
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
@@ -483,7 +483,7 @@ it("Wave C: markers injected mid-run (custom role, follow-up delivery) append to
});
});
// ─── provider failures: one final-entry rule for live and saved records ──────
// ─── provider failures: one marker-aware rule for live and saved records ────
describe("provider-error banner (live and saved)", () => {
function failed(id = "failed", errorMessage: string | undefined = "Google 500: fixture failure"): LhMessage {
@@ -512,7 +512,7 @@ describe("provider-error banner (live and saved)", () => {
assert.equal(model().getState().error, null);
});
it("any later entry supersedes the failure, including plan/compaction markers and aborts", () => {
it("substantive later input/output supersedes the failure, including aborts", () => {
const failure = failed();
const later: LhMessage[] = [
newUserMessage("retry"),
@@ -520,8 +520,6 @@ describe("provider-error banner (live and saved)", () => {
{ ...assistantMsg("aborted", ""), stopReason: "aborted", errorMessage: "Aborted" } as LhMessage,
newThinkingMessage("problem", "my attempt"),
resultMsg("call"),
newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }),
newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }),
];
for (const entry of later) {
const m = model({ initialMessages: [failure] });
@@ -533,6 +531,31 @@ describe("provider-error banner (live and saved)", () => {
}
});
it("plan and compaction markers preserve the banner on sync, adoption and reopen", async () => {
const failure = failed();
const record = [newUserMessage("topic"), failure];
const markers = [
newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }),
newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }),
];
for (const marker of markers) {
const saved = [...record, marker];
let posts = 0;
const m = model({ initialMessages: record, fetch: async () => { posts++; throw new Error("unexpected request"); } });
m.setMessages(saved);
assert.equal(m.getState().error?.message, failure.role === "assistant" ? failure.errorMessage : undefined);
assert.equal(model({ initialMessages: saved }).getState().error?.message, m.getState().error?.message);
const adopted = model({ initialMessages: record });
adopted.adoptRecord(saved);
assert.equal(adopted.getState().error?.message, m.getState().error?.message);
assert.equal(endsInFailedTurn(saved), marker.customType === "plan-block", "later compaction removes Retry eligibility, not the error notice");
await m.continueIfOwed(saved);
assert.equal(posts, 0, "restoring the banner is never an automatic retry");
m.setMessages([...saved, newUserMessage("continue from here")]);
assert.equal(m.getState().error, null, "new learner input supersedes the notice");
}
});
it("a newer live message supersedes the failure before its response finishes", () => {
const m = model();
m.applyWireEvent({ type: "message_end", message: failed() });
@@ -554,7 +577,7 @@ describe("provider-error banner (live and saved)", () => {
assert.equal(m.getState().error?.message, "Google 500: fixture failure");
});
for (const input of [{ type: "text", text: "retry" }, { type: "continue" }] as const) {
for (const input of [{ type: "text", text: "retry" }, { type: "continue" }, { type: "retry" }] as const) {
it(`hides the old failure during the optimistic ${input.type} wait, then shows a new live failure`, async () => {
let acknowledge!: (response: Response) => void;
const ack = new Promise<Response>((resolve) => { acknowledge = resolve; });
@@ -684,6 +707,436 @@ describe("provider-error banner (live and saved)", () => {
});
});
describe("truncated stream recovery", () => {
const truncated = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "partial" }] } });
const completed = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "complete" }] } }, { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "complete" }], stopReason: "stop" } }, "[DONE]");
it("reattaches once after EOF without DONE and replaces the incomplete replay", async () => {
let streams = 0;
const m = model({ fetch: dietFetch({ userText: "go", stream: () => ++streams === 1 ? truncated() : completed() }) });
await m.send({ type: "text", text: "go" });
assert.equal(streams, 2);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
assert.equal(m.getState().nativeMessages.filter(m => m.role === "assistant").length, 1);
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete");
});
it("surfaces an error after a second truncation without retrying the input", async () => {
let streams = 0;
let posts = 0;
const m = model({ fetch: dietFetch({ onPost: () => posts++, stream: () => { streams++; return truncated(); } }) });
await m.send({ type: "text", text: "go" });
assert.equal(posts, 1);
assert.equal(streams, 2);
assert.equal(m.status, "error");
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
});
it("reports the approved connection error when the recovery record read fails", async () => {
let reads = 0;
const m = model({ fetch: async (url) => {
if (String(url) === "/api/chat") return Response.json({ ok: true });
if (String(url).endsWith("/stream")) return truncated();
if (++reads === 1) return Response.json({ messages: [] });
throw new TypeError("synthetic offline");
} });
await m.send({ type: "text", text: "go" });
assert.equal(m.status, "error");
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
});
it("accepts turn-settled when the original turn finished during the disconnection", async () => {
let streams = 0;
let settled = 0;
const m = model({ onReattachSettled: () => { settled++; }, fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : sseResponse({ type: "turn-settled" }, "[DONE]") }) });
await m.send({ type: "text", text: "go" });
assert.equal(streams, 2);
assert.equal(settled, 1);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
});
it("also recovers a rejected body read but does not relabel event-application bugs", async () => {
let streams = 0;
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic read failure")); } })) : completed() }) });
await m.send({ type: "text", text: "go" });
assert.equal(streams, 2);
assert.equal(m.status, "ready");
streams = 0;
const broken = model({ fetch: dietFetch({ stream: () => { streams++; return completed(); } }) });
broken.applyWireEvent = () => { throw new Error("synthetic event application bug"); };
await broken.send({ type: "text", text: "go" });
assert.equal(streams, 1);
assert.equal(broken.getState().error?.message, "synthetic event application bug");
});
it("recovers an AbortError body rejection unless the view was actually cancelled", async () => {
let streams = 0;
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({
start(controller) { controller.error(new DOMException("synthetic unexpected read abort", "AbortError")); },
})) : completed() }) });
await m.send({ type: "text", text: "go" });
assert.equal(streams, 2);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
});
it("uses the approved drop message when recovery record headers arrive but its body read fails", async () => {
let reads = 0;
const m = model({ fetch: async (url) => {
if (String(url) === "/api/chat") return Response.json({ ok: true });
if (String(url).endsWith("/stream")) return truncated();
if (++reads === 1) return Response.json({ messages: [] });
return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic record-body transport failure")); } }));
} });
await m.send({ type: "text", text: "go" });
assert.equal(m.status, "error");
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
});
it("preserves malformed-event diagnostics rather than calling them network drops", async () => {
let streams = 0;
const m = model({ fetch: dietFetch({ stream: () => { streams++; return new Response("data: {broken}\n\n"); } }) });
await m.send({ type: "text", text: "go" });
assert.equal(streams, 1);
assert.equal(m.status, "error");
assert.notEqual(m.getState().error?.message, "The connection dropped before the turn completed.");
assert.match(m.getState().error!.message, /JSON|property name/);
});
it("reports a failed recovery stream request without attempting a third attachment", async () => {
let streams = 0;
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : new Response("synthetic unavailable", { status: 503 }) }) });
await m.send({ type: "text", text: "go" });
assert.equal(streams, 2);
assert.equal(m.status, "error");
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
});
it("a superseded consume cannot recover or overwrite its replacement's state", async () => {
let streams = 0;
let closeFirst!: () => void;
let beganReading!: () => void;
const reading = new Promise<void>(resolve => { beganReading = resolve; });
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({
start(controller) { closeFirst = () => controller.close(); },
pull() { beganReading(); },
}, { highWaterMark: 0 })) : completed() }) });
const first = m.resumeStream(false);
await reading;
await m.resumeStream(false);
closeFirst();
await first;
assert.equal(streams, 2);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete");
});
});
describe("automatic continuation after recovery failure", () => {
const question = { ...assistantMsg("question", ""), content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }] } as LhMessage;
const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true });
const answered = [question, newAnswerResult("q1", "quiz", answer)];
it("a retained failure seeds the banner and blocks continuation even after banner dismissal", async () => {
let posts = 0;
const m = model({ initialMessages: answered, initialError: "The reply could not be saved.", fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
assert.equal(SessionModel.needsContinuation(answered), true);
assert.equal(m.getState().error?.message, "The reply could not be saved.");
await m.continueIfOwed(answered);
assert.equal(posts, 0);
m.clearError();
await m.continueIfOwed(answered);
assert.equal(posts, 0, "closing the banner is not permission to retry the model");
await m.send({ type: "text", text: "explicit learner input" });
assert.equal(posts, 1);
assert.equal(m.getState().error, null);
});
it("a new server-owned turn clears the retained banner without releasing Thinking", () => {
const m = model({ initialMessages: answered, initialError: "The reply could not be saved." });
assert.equal(m.getState().error?.message, "The reply could not be saved.");
assert.equal(m.applyWireEvent({ type: "agent_start" }), false);
assert.equal(m.getState().error, null, "positive new-turn evidence replaces the old transient notice");
});
it("a live failed save cannot auto-continue the accepted answer during canonical sync", async () => {
let posts = 0;
const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
m.applyWireEvent({ type: "error", errorText: "The reply could not be saved." });
m.setMessages(answered);
await m.continueIfOwed(answered);
assert.equal(posts, 0);
});
it("a healthy owed continuation still starts once through the normal send gate", async () => {
let posts = 0;
const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
await m.continueIfOwed(answered);
assert.equal(posts, 1);
});
});
// ─── failed answer submissions (no replay without canonical reconciliation) ──
describe("answer submission recovery", () => {
const question = {
...assistantMsg("question", "Q?"),
content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }],
} as LhMessage;
const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true, thinking: "keep my reasoning" });
it("restores the question after a failed POST and checks the record before another POST", async () => {
const calls: string[] = [];
let posts = 0;
const m = model({ initialMessages: [question], fetch: async (url) => {
calls.push(String(url));
if (String(url) === "/api/chat") {
posts++;
return posts === 1 ? new Response("request refused", { status: 500 }) : Response.json({ ok: true });
}
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
return Response.json({ messages: [question], turnInFlight: false });
} });
await m.submitAnswer("quiz", "q1", answer);
assert.equal(m.getState().messages[0]!.parts.find(p => p.toolCallId === "q1")!.state, "input-available");
assert.equal(m.status, "error");
m.clearError(); // dismissing the banner must not disable recovery
await m.submitAnswer("quiz", "q1", answer);
assert.equal(posts, 2, "error must not permanently close the send gate");
assert.deepEqual(calls.slice(0, 3), ["/api/chat", "/api/chats/chat_x", "/api/chat"]);
});
it("retains all submitted draft channels until the canonical answer resolves the call", async () => {
const form = { selection: { selected: new Set([1]), otherSelected: true, dontKnow: false }, thinking: "reasoning", other: "custom answer", text: "free text", confidenceIndex: 4 };
const m = model({ initialMessages: [question], fetch: async () => { throw new TypeError("offline"); } });
m.saveQuestionDraft("q1", { form });
m.saveQuestionDraft("q1", { readingThinking: "reading reasoning" });
await m.submitAnswer("quiz", "q1", answer);
assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" });
m.setMessages([question]);
assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" });
assert.equal(m.questionDraft("q2"), undefined, "a different question never inherits the draft");
m.adoptRecord([question, newAnswerResult("q1", "quiz", answer)]);
assert.equal(m.questionDraft("q1"), undefined, "only confirmed native resolution clears it");
});
it("a final 409 restores the question instead of leaving an optimistic answer", async () => {
const m = model({ initialMessages: [question], waitMaxMs: 0, fetch: async () => new Response("busy", { status: 409 }) });
await m.submitAnswer("quiz", "q1", answer);
assert.equal(m.getState().nativeMessages.length, 1);
assert.equal(m.status, "error");
});
it("does not repost an answer whose acceptance acknowledgement was lost", async () => {
let posts = 0;
const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[];
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
return Response.json({ messages: saved, turnInFlight: false });
} });
await m.submitAnswer("quiz", "q1", answer);
await m.submitAnswer("quiz", "q1", answer);
assert.equal(posts, 1);
assert.equal(m.status, "ready");
assert.deepEqual(m.getState().nativeMessages, saved);
});
it("reattaches to an accepted running turn instead of posting another answer", async () => {
const calls: string[] = [];
const m = model({ initialMessages: [question], fetch: async (url) => {
calls.push(String(url));
if (String(url) === "/api/chat") throw new TypeError("failed fetch");
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
return Response.json({ messages: [question, newAnswerResult("q1", "quiz", answer)], turnInFlight: true });
} });
await m.submitAnswer("quiz", "q1", answer);
await m.submitAnswer("quiz", "q1", answer);
assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x", "/api/chats/chat_x/stream"]);
assert.equal(m.status, "ready");
});
it("keeps the question and makes no further POST while reconciliation is unreachable", async () => {
const calls: string[] = [];
const m = model({ initialMessages: [question], fetch: async (url) => { calls.push(String(url)); throw new TypeError("offline"); } });
await m.submitAnswer("quiz", "q1", answer);
await m.submitAnswer("quiz", "q1", answer);
assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x"]);
assert.equal(m.status, "error");
assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false);
});
it("does not undo an acknowledged dismissal when an unrelated send's record read fails", async () => {
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url).endsWith("/dismiss") || String(url) === "/api/chat") return Response.json({ ok: true });
throw new TypeError("record read failed");
} });
await m.dismissQuestion("q1", dismissedAnswer("quiz", "Q?"));
await m.send({ type: "text", text: "move on" });
assert.equal(m.status, "error");
const part = m.getState().messages.flatMap(m => m.parts).find(p => p.toolCallId === "q1")!;
assert.equal(part.state, "output-available");
assert.equal((part.output as { dismissed: boolean }).dismissed, true);
});
it("restores an unconfirmed optimistic answer when the post-ack record read fails", async () => {
let posts = 0;
let reads = 0;
const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[];
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); }
if (++reads === 1) throw new TypeError("record read failed");
return Response.json({ messages: saved, turnInFlight: false });
} });
await m.submitAnswer("quiz", "q1", answer);
assert.equal(m.status, "error");
assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false);
await m.submitAnswer("quiz", "q1", answer);
assert.equal(posts, 1, "the acknowledged answer must not be reposted");
assert.equal(m.status, "ready");
assert.deepEqual(m.getState().nativeMessages, saved);
});
for (const next of [{ type: "text", text: "a distinct new message" }, { type: "thinking", problemCallId: "problem1", text: "new practice thinking" }] as const) {
it(`preserves the existing POST/409-wait path for a new ${next.type} submission after an error`, async () => {
const posted: ChatInput[] = [];
const m = model({ pollMs: 0, fetch: async (url, init) => {
if (String(url) === "/api/chat") {
posted.push(JSON.parse(String(init?.body)).input);
if (posted.length === 1) throw new TypeError("failed fetch");
if (posted.length === 2) return new Response("busy", { status: 409 });
return Response.json({ ok: true });
}
if (String(url).startsWith("/api/jobs")) return Response.json({ turnInFlight: false });
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
return Response.json({ messages: [], turnInFlight: true });
} });
await m.send({ type: "text", text: "original input" });
await m.send(next);
assert.deepEqual(posted, [{ type: "text", text: "original input" }, next, next]);
assert.equal(m.status, "ready");
});
}
it("accepts immediate/recovery submissions and dismissals while a live question placeholder is in the tail", async () => {
for (const dismiss of [false, true]) {
let posts = 0;
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url) === "/api/chat" || String(url).endsWith("/dismiss")) {
posts++;
return new Response("refused", { status: 500 });
}
return Response.json({ messages: [question], turnInFlight: false });
} });
m.applyWireEvent({ type: "turn_end", message: question, toolResults: [{ role: "toolResult", toolCallId: "q1", toolName: "quiz",
content: [{ type: "text", text: "Question posed" }], details: null, isError: false, timestamp: 1 }] });
const key = pendingQuestionOf([question])!.key;
if (dismiss) await m.dismissQuestion("q1", answer, key);
else {
await m.submitAnswer("quiz", "q1", answer, key);
await m.submitAnswer("quiz", "q1", answer, key);
}
assert.equal(posts, dismiss ? 1 : 2, "placeholder must not reject the render's question identity");
}
});
it("does not mistake an old same-id answer for resolution of the current question during recovery", async () => {
let posts = 0;
const newer = { ...question, id: "new-question" } as LhMessage;
const record = [question, newAnswerResult("q1", "quiz", answer), newer];
const m = model({ initialMessages: record, fetch: async (url) => {
if (String(url) === "/api/chat") { posts++; return new Response("refused", { status: 500 }); }
return Response.json({ messages: record, turnInFlight: false });
} });
const key = pendingQuestionOf(record)!.key;
m.saveQuestionDraft(key, { readingThinking: "NEW reasoning" });
await m.submitAnswer("quiz", "q1", answer, key);
await m.submitAnswer("quiz", "q1", answer, key);
assert.equal(posts, 2, "an older occurrence's result does not forbid retrying this one");
assert.equal(m.questionDraft(key)?.readingThinking, "NEW reasoning");
m.setMessages([...record, newAnswerResult("q1", "quiz", answer)]);
assert.equal(m.questionDraft(key), undefined);
});
it("adopts a newer same-id occurrence instead of reposting an older question's answer", async () => {
let posts = 0;
const newer = { ...question, id: "new-question" } as LhMessage;
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url) === "/api/chat") { posts++; throw new TypeError("offline"); }
return Response.json({ messages: [question, newer], turnInFlight: false });
} });
const key = pendingQuestionOf([question])!.key;
await m.submitAnswer("quiz", "q1", answer, key);
await m.submitAnswer("quiz", "q1", answer, key);
assert.equal(posts, 1);
assert.equal(m.status, "ready");
assert.equal(pendingQuestionOf(m.getState().nativeMessages)?.messageId, "new-question");
});
it("rejects stale render callbacks locally, including same-id silent dismissals", async () => {
let requests = 0;
const m = model({ initialMessages: [question], fetch: async () => { requests++; return Response.json({ ok: true }); } });
const oldKey = pendingQuestionOf([question])!.key;
const newer = { ...question, id: "new-question" } as LhMessage;
m.setMessages([question, newer]);
await m.submitAnswer("quiz", "q1", answer, oldKey);
await m.dismissQuestion("q1", answer, oldKey);
assert.equal(requests, 0);
assert.equal(m.getState().nativeMessages.length, 2, "no optimistic answer targets the newer question");
});
it("uses the newest occurrence's tool for optimistic answers and dismissals", async () => {
const older = { ...question, id: "old-ask", content: [{ type: "toolCall", id: "q1", name: "ask_user_question", arguments: {} }] } as LhMessage;
for (const dismiss of [false, true]) {
const m = model({ initialMessages: [older, question], fetch: async () => { throw new TypeError("offline"); } });
const key = pendingQuestionOf([older, question])!.key;
let optimisticTool: string | undefined;
m.onChange = () => {
const result = m.getState().nativeMessages.find(m => m.role === "toolResult");
if (result?.role === "toolResult") optimisticTool = result.toolName;
};
if (dismiss) await m.dismissQuestion("q1", answer, key);
else await m.submitAnswer("quiz", "q1", answer, key);
assert.equal(optimisticTool, "quiz");
}
});
it("does not leak drafts between same-id questions, and preserves unique-id live handover", () => {
const m = model({ initialMessages: [question] });
const oldKey = pendingQuestionOf([question])!.key;
m.saveQuestionDraft(oldKey, { readingThinking: "old reasoning" });
const newer = { ...question, id: "new-question" } as LhMessage;
m.setMessages([question, newer]);
const newKey = pendingQuestionOf([question, newer])!.key;
assert.notEqual(oldKey, newKey);
assert.equal(m.questionDraft(newKey), undefined);
assert.equal(m.questionDraft(oldKey), undefined);
const live = { ...question, id: "c_live" };
m.setMessages([live]);
const liveKey = pendingQuestionOf([live])!.key;
m.saveQuestionDraft(liveKey, { readingThinking: "live reasoning" });
m.setMessages([question]);
assert.equal(m.questionDraft(pendingQuestionOf([question])!.key)?.readingThinking, "live reasoning");
});
it("adopts a changed pending question instead of sending the stale answer", async () => {
let posts = 0;
const newer = { ...question, id: "new-question", content: [{ type: "toolCall", id: "q2", name: "quiz", arguments: {} }] } as LhMessage;
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
return Response.json({ messages: [question, newer], turnInFlight: false });
} });
await m.submitAnswer("quiz", "q1", answer);
await m.submitAnswer("quiz", "q1", answer);
assert.equal(posts, 1);
assert.deepEqual(m.getState().nativeMessages, [question, newer]);
assert.equal(m.status, "ready");
});
});
// ─── reattach (resumeStream) ────────────────────────────────────────────────
describe("resumeStream (the reattach path)", () => {
+2 -1
View File
@@ -81,13 +81,14 @@ describe("sessionFindRanges — the displayed-text DOM seam", () => {
assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
});
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, textareas (display:none is browser-verified)", () => {
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, draft editors (display:none is browser-verified)", () => {
mount(`<div data-session-find-root="">
<p>needle visible</p>
<p hidden>needle hidden-attr</p>
<div data-lh-menu><p>needle menu</p></div>
<span class="katex-mathml">needle mathml</span>
<textarea>needle draft</textarea>
<div contenteditable="true" data-file-path-input>needle path draft <span contenteditable="false">notes.pdf</span></div>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);
+5 -3
View File
@@ -234,12 +234,14 @@ describe("parseCompactionSettingsBody (owner round 14)", () => {
});
describe("SETTINGS_SECTIONS (the route's section map)", () => {
it("maps exactly the seven record sections", () => {
it("maps exactly the eight record sections", () => {
// compaction joined (owner round 14): its own section PUT — the
// threshold is engine behavior, not an agent-section key.
// threshold is engine behavior, not an agent-section key. The custom
// endpoint is NOT a section: it is one more provider in the Keys list,
// connected through POST /api/custom-provider.
assert.deepEqual(
Object.keys(SETTINGS_SECTIONS),
["general", "chat", "agent", "appearance", "graph", "compaction", "models"],
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
);
});
});
+1 -1
View File
@@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
});
it("setupStage: keys before models (owner's dependency ordering)", () => {
assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback");
assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing");
assert.equal(setupStage([]), "keys", "no providers = keys");
assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless");
assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");
+7 -2
View File
@@ -172,10 +172,15 @@ describe("setup polish interactions", () => {
assert.equal(closed, 0);
});
it("the empty vision list gives the approved next action without an em dash", async () => {
it("the vision slot filters out built-in text-only models (image-capable or custom only)", async () => {
await mount({ keyed: true });
await click(element('[data-testid="setup-trigger-vision"]'));
assert.ok(element('[data-testid="setup-menu-vision"]').textContent?.includes("No image-capable models. Add a provider key with access to a vision model."));
// The text-only openrouter fixture is neither image-capable nor a custom-
// endpoint model, so the vision slot filters it out (built-ins must report
// image input; only custom models stay selectable when capability is unknown).
const menu = element('[data-testid="setup-menu-vision"]');
assert.ok(!menu.textContent?.includes("fixture-text"));
assert.ok(menu.textContent?.includes("No models match."));
});
it("catalog failure uses the approved two-sentence error", async () => {
+1
View File
@@ -35,6 +35,7 @@ async function fixture(options: { login?: (id: string, interaction: ProviderAuth
test("main loader resolves Pi's import-only PUBLIC package exports and native OAuth without a CLI or network", async () => {
const sdk = await loadSubscriptionSDK(path.resolve(import.meta.dirname, ".."));
assert.equal(typeof sdk.createModels, "function");
assert.throws(() => require("@earendil-works/pi-ai"), { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }, "the temporary import resolver must not alter later CommonJS resolution");
for (const [id, factory] of [["github-copilot", sdk.githubCopilotProvider], ["xai", sdk.xaiProvider]] as const) {
const provider = factory(), store = new sdk.InMemoryCredentialStore(); await store.modify(id, () => credential(id));
const models = sdk.createModels({ credentials: store }); models.setProvider({ ...provider, auth: { oauth: provider.auth.oauth } });
+2 -2
View File
@@ -42,7 +42,7 @@ for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, tru
const chat = stores.chatStore.createChat("Fixture mixed queue"); const model = { ...piModels().getModels(p.providerId)[0]!, provider: p.logicalId };
stores.chatStore.saveSettings(chat.id, { model: { provider: p.logicalId, id: model.id } });
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "fixture-job-call" }); const result = { brief: "Fixture helper result", sources: [] };
stores.jobStore.updateJob(job.id, { status: "running", result }); const steer = buildSubagentSteer(job, result); const contexts: string[] = [];
stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } }); const steer = buildSubagentSteer(job, result); const contexts: string[] = [];
const agent = new Agent({ initialState: { model, messages: [{ id: "fixture-initial", role: "user", content: "Initial learner input", timestamp: 1 } as AgentMessage] }, convertToLlm: toLlmMessages, streamFn: () => {
contexts.push(JSON.stringify(agent.state.messages)); const stream = new AssistantMessageEventStream();
const output = { role: "assistant" as const, provider: p.logicalId, api: model.api, model: model.id, content: [{ type: "text" as const, text: "Foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
@@ -57,6 +57,6 @@ for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, tru
assert.equal(contexts.length, 1); assert(contexts[0]!.includes("Continue my learning")); assert(!contexts[0]!.includes(steer.id));
const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted); await new Promise((resolve) => setImmediate(resolve));
assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "running");
assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "done"); assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
connection(p.providerId, true); await delivered; assert.equal(wakes, 1); assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
});
+207
View File
@@ -0,0 +1,207 @@
import React, { act, useState } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { VisualOutputCard } from "../components/artifacts/visual-card.tsx";
import { svgImageSource } from "../components/artifacts/svg-image.tsx";
import { MermaidOverlay, SessionMermaidContext } from "../components/session-overlay.tsx";
// Structure-only checks: linkedom does not load images or implement CSS cascade.
// No fixture script, link, or external reference is executed by these tests.
const FIGURE = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160" viewBox="0 0 320 160"><style>text{font-family:Arial,Helvetica,sans-serif;fill:#202830}</style><defs><marker id="arrow"><path d="M0 0L5 5"/></marker></defs><path marker-end="url(#arrow)" d="M0 0L20 20"/><text>θ &amp; φ: 50% #1</text></svg>`;
const ACTIVE_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" onload="example()"><script>example()</script><foreignObject><div xmlns="http://www.w3.org/1999/xhtml" onclick="example()">Example</div></foreignObject><a href="javascript:example()"><text>Link</text></a><image href="https://example.invalid/image.png"/></svg>`;
const PREFIX = "data:image/svg+xml;charset=utf-8,";
function assertImageBoundary(holder: Element, source: string) {
const image = holder.querySelector("img");
assert.ok(image, "figure is an image");
assert.equal(holder.children.length, 1, "no figure markup alongside the image");
assert.equal(holder.querySelector("svg, style, script, foreignObject, a, [onload], [onclick]"), null,
"generated elements and CSS never enter the app document");
assert.equal(image.getAttribute("alt"), "Figure");
assert.equal(image.getAttribute("draggable"), "false", "native image dragging must not interfere with panning");
const src = image.getAttribute("src")!;
assert.ok(src.startsWith(PREFIX));
assert.equal(decodeURIComponent(src.slice(PREFIX.length)), source, "image URL contains the expected SVG display source, not a raster");
return image;
}
describe("SVG display compatibility", () => {
it("leaves standalone SVG unchanged, including comments and CDATA", () => {
const source = FIGURE.replace("</svg>", '<!-- &nbsp; <svg> --><![CDATA[&nbsp; <svg>]]></svg>');
assert.equal(svgImageSource(source), source);
});
it("adds only a missing root namespace, ignoring comments and attribute values", () => {
const source = '<?xml version="1.0"?><!-- <svg xmlns="wrong"> --><svg data-note=\' xmlns="not-a-declaration" >\' xmlns:xlink="http://www.w3.org/1999/xlink"><svg/></svg>';
const expected = source.replace('<svg data-note=', '<svg xmlns="http://www.w3.org/2000/svg" data-note=');
assert.equal(svgImageSource(source), expected);
assert.equal(svgImageSource(expected), expected, "normalization is idempotent");
});
it("does not replace an explicit namespace declaration", () => {
for (const declaration of ['xmlns="http://www.w3.org/2000/svg"', "xmlns = 'other'", 'xmlns=""']) {
const source = `<svg ${declaration}><text>Example</text></svg>`;
assert.equal(svgImageSource(source), source);
}
});
it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => {
const source = '<?example &nbsp;?><svg xmlns="http://www.w3.org/2000/svg" aria-label="A&nbsp;B"><text>A&nbsp;B &amp;nbsp; &#160;</text><!-- &nbsp; --><![CDATA[&nbsp;]]></svg>';
const expected = source.replace('aria-label="A&nbsp;B"', 'aria-label="A&#160;B"').replace('<text>A&nbsp;B', '<text>A&#160;B');
assert.equal(svgImageSource(source), expected);
assert.equal(svgImageSource(expected), expected);
});
});
describe("generated SVG image boundary", () => {
for (const mode of ["inline", "fullscreen"] as const) {
for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) {
it(`${mode}: isolates ${name} inside the image URL`, () => {
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={{ svg: source }} />
: <MermaidOverlay svg={source} onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assertImageBoundary(holder, source);
assert.ok(holder.classList.contains("bg-white"), "white backing retained");
});
}
for (const [name, original, display] of [
["missing namespace", '<svg width="320" height="160"><text>Example</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160"><text>Example</text></svg>'],
["HTML entity", '<svg xmlns="http://www.w3.org/2000/svg"><text>A&nbsp;B</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg"><text>A&#160;B</text></svg>'],
] as const) {
it(`${mode}: repairs ${name} only in the image source`, () => {
const output = { svg: original };
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={output} />
: <MermaidOverlay svg={output.svg} onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assertImageBoundary(holder, display);
assert.equal(output.svg, original, "artifact source was not mutated");
});
}
it(`${mode}: retains inversion on the white backing`, () => {
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={{ svg: FIGURE }} initialInverted />
: <MermaidOverlay svg={FIGURE} inverted onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
assert.ok(holder.classList.contains("bg-white"));
assertImageBoundary(holder, FIGURE);
});
}
it("inline: retains natural width and only shrinks oversized figures", () => {
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: FIGURE }} />));
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE);
assert.equal(image.getAttribute("width"), "320");
assert.equal(image.className, "block h-auto max-w-full");
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit"));
});
for (const width of ["", ' width="100%"']) {
it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => {
const source = `<svg xmlns="http://www.w3.org/2000/svg"${width} viewBox="0 0 320 160"><text>Example</text></svg>`;
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: source }} />));
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source);
assert.equal(image.getAttribute("width"), null);
assert.equal(image.className, "block h-auto w-full");
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full"));
});
}
it("fullscreen: keeps the natural-size box and zoom controls", () => {
const { document } = parseHTML(renderToStaticMarkup(<MermaidOverlay svg={FIGURE} onClose={() => {}} />));
const holder = document.querySelector("[data-lh-overlay-chart]")!;
assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full");
assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/);
for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) {
assert.ok(document.querySelector(`button[aria-label="${label}"]`));
}
});
});
it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => {
const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ &amp; φ", "θ&nbsp;&amp; φ");
const displaySvg = FIGURE.replace("θ &amp; φ", "θ&#160;&amp; φ");
const dom = parseHTML("<html><body></body></html>");
const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const;
const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
Object.assign(globalThis, {
window: dom.window,
document: dom.document,
Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
const host = dom.document.createElement("div");
dom.document.body.append(host);
const root = createRoot(host);
const originalCreateUrl = URL.createObjectURL;
const originalRevokeUrl = URL.revokeObjectURL;
const originalCreateElement = dom.document.createElement;
let downloaded: Blob | undefined;
let revoked: string | undefined;
let clicked: { name: string; href: string } | undefined;
URL.createObjectURL = (blob) => {
assert.ok(blob instanceof Blob);
downloaded = blob;
return "blob:figure-test";
};
URL.revokeObjectURL = (url) => { revoked = url; };
function Harness() {
const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null);
return (
<SessionMermaidContext.Provider value={{ openMermaid: () => {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}>
<VisualOutputCard output={{ svg: originalSvg }} initialInverted />
{focus && <MermaidOverlay svg={focus.svg} inverted={focus.inverted} onClose={() => setFocus(null)} />}
</SessionMermaidContext.Provider>
);
}
try {
await act(async () => root.render(<Harness />));
await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg);
const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!;
const fullscreen = assertImageBoundary(overlayHolder, displaySvg);
assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src"));
assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
// Intercept only the download anchor; never navigate or write a real file.
dom.document.createElement = ((...args: Parameters<typeof originalCreateElement>) => {
const element = originalCreateElement.apply(dom.document, args);
if (args[0] === "a") {
element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; };
}
return element;
}) as typeof originalCreateElement;
await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
assert.ok(downloaded);
assert.equal(await downloaded.text(), originalSvg);
assert.equal(downloaded.type, "image/svg+xml");
assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" });
assert.equal(revoked, "blob:figure-test");
} finally {
try {
await act(async () => root.unmount());
} finally {
URL.createObjectURL = originalCreateUrl;
URL.revokeObjectURL = originalRevokeUrl;
dom.document.createElement = originalCreateElement;
host.remove();
for (const [i, name] of names.entries()) {
if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!);
else Reflect.deleteProperty(globalThis, name);
}
}
}
});
+78
View File
@@ -0,0 +1,78 @@
import React, { act } from "react";
(globalThis as { React?: typeof React }).React = React;
import assert from "node:assert/strict";
import { test } from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { QuestionDock } from "../components/question-dock.tsx";
import { AnsweredCard } from "../components/answered-card.tsx";
import { newAnswerResult, type QuestionAnswerData } 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, IS_REACT_ACT_ENVIRONMENT: true });
test("actual select → submit → saved result → historical Why uses source data and presentation-only recovery", async () => {
const question = String.raw`Find $\\nabla f$ for $\begin{pmatrix}a&b\\c&d\end{pmatrix}$.`;
const label = String.raw`$\\nabla f$`;
const explanation = String.raw`First paragraph.\n\nThe matrix is $\begin{pmatrix}p&q\\r&s\end{pmatrix}$ and $\\nabla f$ is its gradient.`;
const input = { question, options: [{ label }, { label: "no" }], correctAnswer: label, explanation };
const before = structuredClone(input);
let answer: QuestionAnswerData | undefined;
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
await act(async () => root.render(<QuestionDock toolName="probe" toolCallId="interactive_source" input={input} expanded
dictationClaim={false} onExpand={() => {}} onSubmit={a => { answer = a; }} />));
assert.equal(host.querySelectorAll("mtr").length, 2, "the live matrix retains two rows");
const choice = [...host.querySelectorAll<HTMLButtonElement>('[role="radio"]')].find(button => button.querySelector(".katex"))!;
assert.ok(choice);
await act(async () => choice.click());
assert.equal(choice.getAttribute("aria-checked"), "true");
const submit = [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent?.includes("Submit answer"))!;
assert.equal(submit.disabled, false);
// linkedom does not implement the browser's default button→form
// action; dispatch its native submit event through React instead.
await act(async () => submit.closest("form")!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })));
assert.ok(answer);
assert.equal(answer.questionText, question);
assert.equal(answer.explanation, explanation);
assert.equal(answer.answers[0]!.value, label, "display recovery must not alter the submitted identity");
assert.equal(answer.answers[0]!.label, label);
assert.equal(answer.correct, true);
const persisted = JSON.parse(JSON.stringify(newAnswerResult("interactive_source", "probe", answer)));
assert.deepEqual(persisted.details, JSON.parse(JSON.stringify(answer)));
assert.equal(persisted.details.explanation, explanation);
const saved = structuredClone(persisted.details);
await act(async () => root.render(<AnsweredCard answer={persisted.details} input={input} />));
const why = [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Why")!;
await act(async () => why.click());
assert.equal(host.querySelectorAll("mtr").length, 4, "question and revealed explanation retain their rows");
const paragraphs = host.querySelectorAll('[data-testid="tucked-explanation"] p');
assert.equal(paragraphs.length, 2, "recovered paragraphs remain distinct in Why");
assert.ok(paragraphs[0]!.parentElement!.classList.contains("[&>p:first-child]:inline"));
assert.ok(!paragraphs[0]!.parentElement!.classList.contains("[&_p]:inline"));
const annotations = [...host.querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent);
assert.equal(annotations.filter(t => t === String.raw`\nabla f`).length, 3);
assert.deepEqual(persisted.details, saved);
assert.deepEqual(input, before);
} finally { await act(async () => root.unmount()); host.remove(); }
});
test("committed-answer reveals keep the first paragraph inline without flattening later paragraphs", async () => {
const committed = String.raw`First paragraph.\n\nSecond paragraph with $\\nabla f$ and $\nu$.`;
const answer: QuestionAnswerData = { kind: "explain", questionText: "Explain the gradient.", answers: [], text: "Synthetic answer", committed, dontKnow: false, answeredAt: "2026-10-26T00:00:00.000Z" };
const input = { question: answer.questionText, correctAnswer: committed };
const before = structuredClone(answer);
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
await act(async () => root.render(<AnsweredCard answer={answer} input={input} />));
const paragraphs = host.querySelectorAll("p");
assert.equal(paragraphs.length, 3, "question plus two committed-answer paragraphs");
assert.ok(paragraphs[1]!.parentElement!.classList.contains("[&>p:first-child]:inline"));
assert.ok(!paragraphs[1]!.parentElement!.classList.contains("[&_p]:inline"));
assert.equal(host.querySelectorAll(".katex-error").length, 0);
assert.deepEqual(answer, before);
} finally { await act(async () => root.unmount()); host.remove(); }
});
+164
View File
@@ -0,0 +1,164 @@
import React from "react";
(globalThis as { React?: typeof React }).React = React;
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx";
import { QuestionDock } from "../components/question-dock.tsx";
import { AnsweredCard } from "../components/answered-card.tsx";
import { answerForPresentation, buildAnswer, normalizeOptions, parseAskInput, parseExplainInput, parseQuizInput,
repairMathForDisplay, resolveCorrect, type QuestionAnswerData, type QuestionData } from "../src/core/index.ts";
const fixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/matrix-regressions.json", import.meta.url), "utf8")) as {
cases: Array<{ chatId: string; source: string; raw: string; oldDisplay: string }>;
};
const a = String.raw`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`;
const b = String.raw`$\begin{pmatrix}p&q\\r&s\end{pmatrix}$`;
const brokenA = a.replace(String.raw`\\c`, String.raw`\c`);
const brokenB = b.replace(String.raw`\\r`, String.raw`\r`);
const doubled = String.raw`$\\nabla f$`;
const input = { question: a, options: [{ label: a, value: "a" }, { label: b, value: "b" }], correctAnswer: "a", explanation: a };
const dom = (html: string) => parseHTML(`<html><body>${html}</body></html>`).document;
const render = (text: string, recovered = true) => renderToStaticMarkup(
recovered ? <ToolContentResponse mode="static">{text}</ToolContentResponse> : <MessageResponse mode="static">{text}</MessageResponse>);
const tex = (html: string) => [...dom(html).querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent);
for (const fixture of fixtures.cases) test(`preserves corpus matrix rows: ${fixture.chatId} ${fixture.source}`, () => {
assert.notEqual(fixture.raw, fixture.oldDisplay);
assert.equal(repairMathForDisplay(fixture.raw), fixture.raw);
const expected = dom(render(fixture.raw, false));
const actual = dom(render(fixture.raw));
assert.deepEqual(tex(actual.body.innerHTML), tex(expected.body.innerHTML));
assert.equal(actual.querySelectorAll("mtr").length, expected.querySelectorAll("mtr").length);
assert.ok(actual.querySelectorAll("mtr").length > dom(render(fixture.oldDisplay, false)).querySelectorAll("mtr").length);
assert.equal(actual.querySelectorAll(".katex-error").length, 0);
});
test("generic nested environments, comments, and bare row-leading command names are protected", () => {
for (const environment of ["matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "smallmatrix", "array", "aligned", "cases"]) {
const source = `$\\begin{${environment}}a&b\\\\beta&d\\end{${environment}}$`;
assert.equal(repairMathForDisplay(source), source);
}
const nested = "$$\n" + String.raw`\begin{aligned}x&=\begin{pmatrix}a&b\\c&d\end{pmatrix}\\% \end{aligned} is a comment` + "\n" + String.raw`r&=s\end{aligned}` + "\n$$";
assert.equal(repairMathForDisplay(nested), nested);
const spaced = String.raw`$\begin {pmatrix}a&b\\c&d\end {pmatrix}$`;
assert.equal(repairMathForDisplay(spaced), spaced);
const mixed = String.raw`$\\lambda+\begin{pmatrix}a&b\\c&d\end{pmatrix}$`;
assert.equal(repairMathForDisplay(mixed), mixed.replace(String.raw`\\lambda`, String.raw`\lambda`));
});
test("corpus document framing and final-display percentages recover without new errors", () => {
const cases = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/document-percent-regressions.json", import.meta.url), "utf8"));
for (const fixture of cases) {
const recovered = repairMathForDisplay(fixture.raw);
const document = dom(render(fixture.raw));
assert.equal(document.querySelectorAll(".katex-error").length, 0, fixture.chatId);
assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId);
if (fixture.kind === "percent-at-display-end") {
assert.ok([...document.querySelectorAll(".katex-html")].some(node => node.textContent?.includes("%")), fixture.chatId);
assert.ok(!recovered.includes("\\\\%"), fixture.chatId);
} else {
assert.match(recovered, /\n\$\$\n/);
assert.match(recovered, /\n\$\$$/);
}
}
});
test("mixed-escaped environment markers retain existing recovery rather than treating an opener as native", () => {
const mixedFixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/mixed-escaping-regressions.json", import.meta.url), "utf8")) as typeof fixtures;
assert.equal(mixedFixtures.cases.length, 2);
for (const fixture of mixedFixtures.cases) {
const recovered = repairMathForDisplay(fixture.raw);
assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId);
assert.doesNotMatch(recovered, /\\\\end\{/);
assert.equal(dom(render(fixture.raw)).querySelectorAll(".katex-error").length, 0);
}
});
test("presentation-equivalent labels cannot make a source-invalid correctAnswer gradeable", () => {
const source = { question: "Which?", options: [{ label: String.raw`$\nabla f$` }, { label: "no" }],
correctAnswer: doubled, explanation: "A model-authored identity mismatch" };
const before = structuredClone(source);
assert.equal(repairMathForDisplay(doubled), source.options[0]!.label);
assert.equal(parseQuizInput(source), undefined);
assert.deepEqual(source, before);
});
test("shape parsers retain source text and identities; recovery is presentation only", () => {
const quiz = { ...input, question: doubled, explanation: "$\forall x$", options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled };
const ask = { question: doubled, options: [{ label: doubled, value: doubled, description: doubled }, { label: "no" }] };
const explain = { question: doubled, correctAnswer: "$\forall x$" };
const before = structuredClone({ quiz, ask, explain });
assert.equal(parseQuizInput(quiz)!.question, doubled);
assert.equal(parseQuizInput(quiz)!.explanation, quiz.explanation);
assert.equal(parseQuizInput(quiz)!.correctAnswer, doubled);
assert.deepEqual(parseAskInput(ask), ask);
assert.deepEqual(parseExplainInput(explain), explain);
assert.deepEqual(tex(render(doubled)), [String.raw`\nabla f`]);
assert.deepEqual(tex(render(doubled, false)), [String.raw`\\nabla f`]);
assert.deepEqual({ quiz, ask, explain }, before);
});
test("the live dock recovers only its rendering, including question, context and option labels", () => {
const source = { question: doubled, details: doubled, options: [{ label: doubled }, { label: "no" }] };
const before = structuredClone(source);
const html = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="source_q" input={source} expanded onExpand={() => {}} onSubmit={() => {}} />);
assert.deepEqual(tex(html), Array(3).fill(String.raw`\nabla f`));
assert.deepEqual(source, before);
});
test("answers snapshot source text, not recovered labels or identities", () => {
const source = { ...input, question: doubled, explanation: doubled, options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled };
const parsed = parseQuizInput(source)!;
const options = normalizeOptions(parsed.options);
const qd: QuestionData = { id: "source", kind: "quiz", question: parsed.question, mode: "single-select", options,
correctIndices: resolveCorrect(parsed.correctAnswer, options).indices, explanation: parsed.explanation, createdAt: "synthetic" };
const answer = buildAnswer(qd, { values: [doubled], confidence: 70 }, "probe");
assert.equal(answer.questionText, doubled);
assert.equal(answer.options![0]!.label, doubled);
assert.equal(answer.answers[0]!.value, doubled);
assert.equal(answer.answers[0]!.label, doubled);
assert.equal(answer.explanation, doubled);
assert.equal(answer.correct, true);
const before = structuredClone(answer);
assert.deepEqual(tex(renderToStaticMarkup(<AnsweredCard answer={answer} input={source} />)), Array(2).fill(String.raw`\nabla f`));
assert.deepEqual(answer, before);
});
function historical(): QuestionAnswerData {
return { kind: "quiz", questionText: brokenA, options: [{ index: 1, label: brokenB }, { index: 2, label: brokenA }],
answers: [{ index: 1, label: brokenB, value: "b" }], correct: false, correctIndices: [2], dontKnow: false,
explanation: brokenA, confidence: 70, answeredAt: "synthetic" };
}
test("historical source join restores matrices while retaining recorded order, answer and grade", () => {
const answer = historical();
const before = structuredClone({ answer, input });
const projection = answerForPresentation(answer, input);
assert.equal(projection.questionText, a);
assert.equal(projection.explanation, a);
assert.deepEqual(projection.options, [{ index: 1, label: b }, { index: 2, label: a }]);
assert.equal(projection.answers, answer.answers);
assert.equal(projection.correctIndices, answer.correctIndices);
assert.equal(projection.correct, false);
assert.equal(projection.confidence, 70);
const html = renderToStaticMarkup(<AnsweredCard answer={answer} input={input} />);
assert.deepEqual(tex(html), [a.slice(1,-1), b.slice(1,-1), a.slice(1,-1)]);
assert.match(html, /incorrect/);
assert.deepEqual({ answer, input }, before);
});
test("historical ambiguous options and absent/invalid original calls retain their snapshots", () => {
const source = { ...input, options: [...input.options, { label: String.raw`$\begin{matrix}x\\y\end{matrix}$`, value: "c" }] };
const answer = { ...historical(), answers: [], dontKnow: true, options: [
{ index: 1, label: brokenB }, { index: 2, label: brokenA }, { index: 3, label: String.raw`$\begin{matrix}x\y\end{matrix}$` },
] };
assert.deepEqual(answerForPresentation(answer, source).options, answer.options);
assert.equal(answerForPresentation(answer, undefined), answer);
assert.equal(answerForPresentation(answer, { question: a }), answer);
const dismissed = { kind: "quiz", dismissed: true } as QuestionAnswerData;
assert.equal(answerForPresentation(dismissed, input), dismissed);
const before = structuredClone(answer);
renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.deepEqual(answer, before);
});
+30 -32
View File
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { quizInputProblems } from "../src/core/question-core.ts";
import { Value } from "typebox/value";
import { restoreMathEscapes } from "../src/core/math-delims.ts";
import { repairMathForDisplay, restoreMathEscapes } from "../src/core/math-delims.ts";
import {
parseAskInput,
parseProbeDoneInput,
@@ -170,7 +170,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26
// The exact live shapes from the modal-logic session: single-backslash
// LaTeX inside JSON args — JSON escape resolution ate the command's
// first letter (\f → FF ate 'f' of \forall, \v → VT ate 'v' of \varphi).
it("parseQuizInput restores escape debris inside math spans (question + options + explanation)", () => {
it("presentation restores debris from parsed source fields (question + options + explanation)", () => {
const parsed = parseQuizInput({
question: "What does \\(\\forall x\\, \\varphi(x)\\) mean?",
options: [
@@ -182,18 +182,18 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26
});
assert.ok(parsed);
assert.equal(parsed.question, "What does \\(\\forall x\\, \\varphi(x)\\) mean?");
assert.equal(parsed.options[0]!.label, "\\(\\varphi\\) is true when every element satisfies it");
assert.equal(parsed.options[1]!.label, "\\(\\forall\\) and \\(\\exists\\) are just shorthand");
assert.equal(parsed.explanation, "\\(\\forall\\) quantifies over the domain.");
assert.equal(repairMathForDisplay(parsed.options[0]!.label), "\\(\\varphi\\) is true when every element satisfies it");
assert.equal(repairMathForDisplay(parsed.options[1]!.label), "\\(\\forall\\) and \\(\\exists\\) are just shorthand");
assert.equal(repairMathForDisplay(parsed.explanation), "\\(\\forall\\) quantifies over the domain.");
});
it("parseAskInput restores debris in ask option labels too", () => {
it("presentation restores debris from parsed ask option labels too", () => {
const parsed = parseAskInput({
question: "Goal?",
options: [{ label: "\\(\x0corall\\) rigor" }, { label: "Intuition first" }],
});
assert.ok(parsed);
assert.equal(parsed.options![0]!.label, "\\(\\forall\\) rigor");
assert.equal(repairMathForDisplay(parsed.options![0]!.label), "\\(\\forall\\) rigor");
});
it("restores TAB debris (the \\to case) inside spans only", () => {
@@ -204,7 +204,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26
explanation: "",
});
assert.ok(parsed);
assert.equal(parsed.question, "Is \\(p\\to q\\) valid?");
assert.equal(repairMathForDisplay(parsed.question), "Is \\(p\\to q\\) valid?");
});
it("control chars OUTSIDE math spans are left alone (real prose/pasted text)", () => {
@@ -253,7 +253,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
explanation: "",
});
assert.ok(parsed);
assert.equal(parsed.question, "Evaluate \\[\\left( a \\right)\\] carefully.");
assert.equal(repairMathForDisplay(parsed.question), "Evaluate \\[\\left( a \\right)\\] carefully.");
});
it("sloppy \\neq in display math stays broken — the accepted cost (newline kept, byte-identical to a real line break)", () => {
@@ -276,7 +276,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
explanation: "",
});
assert.ok(parsed);
assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?");
assert.equal(repairMathForDisplay(parsed.question), "Is \\(p \\neq q\\) valid?");
});
});
@@ -289,9 +289,9 @@ describe("dollar-delimiter recognition — existing escape restoration rules", (
explanation: "Here $p\to q$ is an implication.",
});
assert.ok(parsed);
assert.equal(parsed.question, "What does $\\forall x$ mean?");
assert.equal(parsed.options[0]!.label, "$\\varphi$ holds");
assert.equal(parsed.explanation, "Here $p\\to q$ is an implication.");
assert.equal(repairMathForDisplay(parsed.question), "What does $\\forall x$ mean?");
assert.equal(repairMathForDisplay(parsed.options[0]!.label), "$\\varphi$ holds");
assert.equal(repairMathForDisplay(parsed.explanation), "Here $p\\to q$ is an implication.");
});
it("keeps the same six-character inline recovery map", () => {
@@ -320,21 +320,21 @@ describe("dollar-delimiter recognition — existing escape restoration rules", (
}
});
it("preserves legacy replacement frequency when a span has multiple debris characters", () => {
it("restores all debris in one pass consistently across delimiter syntaxes", () => {
const body = "p\to q, \rho";
const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2);
assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`);
assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged");
assert.equal(legacy, "p\\to q, \\rho", "recovery converges instead of requiring a second render");
});
it("shares dollar recovery with ask and free-recall fields", () => {
const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] });
assert.ok(ask);
assert.equal(ask.options![0]!.label, "$\\forall$ rigor");
assert.equal(repairMathForDisplay(ask.options![0]!.label), "$\\forall$ rigor");
const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." });
assert.ok(explain);
assert.equal(explain.question, "Explain $p\\to q$");
assert.equal(explain.correctAnswer, "$p\\neq q$ is different.");
assert.equal(repairMathForDisplay(explain.question), "Explain $p\\to q$");
assert.equal(repairMathForDisplay(explain.correctAnswer), "$p\\neq q$ is different.");
});
it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => {
@@ -401,13 +401,9 @@ describe("parseExplainInput: the dock's display seam for explain calls", () => {
});
});
describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)", () => {
// xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash stochastically DOUBLE
// LaTeX command backslashes inside JSON tool arguments — per-call and
// per-field (gradient-descent msg 3: question doubled, explanation and
// options in the SAME call clean). restoreMathEscapesDeep now rides
// healOverescapedCommands after the debris restore, so doubled spans
// heal at every display parse: live dock, settled cards, old records.
describe("over-escaped command recovery after shape parsing", () => {
// Keep these real authoring shapes, but recover only for presentation.
// Parsed source is also used to build durable answers and identities.
const CLEAN =
"Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. Among all unit directions $\\|u\\|_2 = 1$, which makes $D_u f(x)$ most negative?";
const DOUBLE = (s: string): string => s.replace(/\\/g, "\\\\");
@@ -420,19 +416,21 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)"
explanation: "By Cauchy\u2013Schwarz: $$u^\\star = -\\frac{\\nabla f(x)}{\\|\\nabla f(x)\\|_2}$$",
};
it("parseQuizInput heals a doubled question field (the gradient-descent msg-3 shape)", () => {
it("presentation heals a doubled question field (the gradient-descent msg-3 shape)", () => {
const parsed = parseQuizInput({ ...BASE, question: DOUBLE(CLEAN) });
assert.ok(parsed, "parses");
assert.equal(parsed.question, CLEAN);
assert.equal(parsed.question, DOUBLE(CLEAN), "shape parsing retains source");
assert.equal(repairMathForDisplay(parsed.question), CLEAN);
// The CLEAN sibling fields of the same call stay byte-identical.
assert.equal(parsed.options[0]!.label, BASE.options[0]!.label);
assert.equal(parsed.explanation, BASE.explanation);
});
it("parseQuizInput heals a quadrupled field (the agent's over-correction, msg 6)", () => {
it("presentation heals a quadrupled field (the agent's over-correction, msg 6)", () => {
const parsed = parseQuizInput({ ...BASE, question: DOUBLE(DOUBLE(CLEAN)) });
assert.ok(parsed, "parses");
assert.equal(parsed.question, CLEAN);
assert.equal(parsed.question, DOUBLE(DOUBLE(CLEAN)), "shape parsing retains source");
assert.equal(repairMathForDisplay(parsed.question), CLEAN);
});
it("doubled option labels and explanations heal too (the inference-runtime msg-7 shape)", () => {
@@ -448,10 +446,10 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)"
assert.ok(parsed, "parses");
assert.equal(parsed.options[0]!.label, "$mkn$");
assert.equal(parsed.options[1]!.label, "$2mkn$");
assert.equal(parsed.explanation, "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$");
assert.equal(repairMathForDisplay(parsed.explanation), "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$");
});
it("parseAskInput heals doubled ask-option descriptions the same way", () => {
it("presentation heals doubled ask-option descriptions the same way", () => {
const parsed = parseAskInput({
question: "Depth?",
options: [
@@ -460,7 +458,7 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)"
],
});
assert.ok(parsed, "parses");
assert.equal(parsed.options![0]!.description, "why $-\\nabla f$ is steepest descent");
assert.equal(repairMathForDisplay(parsed.options![0]!.description!), "why $-\\nabla f$ is steepest descent");
});
it("clean records pass through byte-identical (no over-heal)", () => {
+2 -2
View File
@@ -17,7 +17,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Hint, hintText } from "../components/ui/hint.tsx";
import { TOOLTIP_DELAY_MS } from "../components/ui/tooltip.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/update-settings.tsx";
import { IS_MAC } from "../src/core/keyboard.ts";
describe("hintText — the parenthesized composition", () => {
@@ -46,7 +46,7 @@ describe("subtlety round pins (owner 2026-09-25)", () => {
describe("spot-pins: the dev-speak cull (owner copy review at merge)", () => {
it("the auto-update hint is honest user language — no stream jargon", () => {
assert.equal(AUTO_UPDATE_HINT, "Automatic updates arrive in an upcoming release");
assert.equal(AUTO_UPDATE_HINT, "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.");
assert.ok(!AUTO_UPDATE_HINT.includes("wave"), "no stream vocabulary");
});
});
+11
View File
@@ -11,8 +11,19 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
import { subscribe, type ServerEvent } from "../src/core/events.ts";
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
describe("turn liveness registry", () => {
it("retains a late-attach failure notice until the next accepted begin", async () => {
const id = "chat_failure_notice_unit";
rememberTurnFailure(id, "The reply could not be saved.");
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./);
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./, "reading does not consume the notice");
assert.equal(beginTurn(id), true);
assert.doesNotMatch(await settledTurnResponse(id).text(), /"type":"error"/);
endTurn(id);
});
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
const id = "chat_liveness_unit";
assert.equal(isTurnRunning(id), false, "initially idle");

Some files were not shown because too many files have changed in this diff Show More