Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
@@ -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:/);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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" }] },
|
||||
});
|
||||
});
|
||||
@@ -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)"));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"\)/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 +)$."
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, /<reasoning>/);
|
||||
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中文"]);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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] },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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={() => {}} />);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>θ & φ: 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>", '<!-- <svg> --><![CDATA[ <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 ?><svg xmlns="http://www.w3.org/2000/svg" aria-label="A B"><text>A B &nbsp;  </text><!-- --><![CDATA[ ]]></svg>';
|
||||
const expected = source.replace('aria-label="A B"', 'aria-label="A B"').replace('<text>A B', '<text>A 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 B</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg"><text>A 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("θ & φ", "θ & φ");
|
||||
const displaySvg = FIGURE.replace("θ & φ", "θ & φ");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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
@@ -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)", () => {
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user