Merge current main into PR61 finishing branch

This commit is contained in:
amosblomqvist
2026-10-28 07:08:36 +02:00
170 changed files with 10528 additions and 2018 deletions
+299
View File
@@ -0,0 +1,299 @@
/** 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",
setName() {},
getPath: () => userData,
setPath: (_: string, value: string) => { userData = value; },
requestSingleInstanceLock: () => false,
quit() {},
whenReady: async () => {},
});
const electron = {
app, BrowserWindow: FakeWindow,
Menu: { buildFromTemplate: (value: typeof menu) => value, setApplicationMenu: (value: typeof menu) => { menu = value; } },
ipcMain: { on() {}, handle: (channel: string, fn: (event: unknown) => unknown) => handles.set(channel, fn) },
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = createContext({
require: (id: string) => {
if (id === "electron") return electron;
if (id === "electron-updater") {
if (updaterMissing) throw new Error("missing updater");
return { autoUpdater: updater };
}
if (id === "node:fs") return {
readFileSync: (file: string) => {
settingsReads.push(file);
if (settings instanceof Error) throw settings;
return settings;
}, mkdirSync() {}, appendFileSync() {},
};
return mainRequire(id);
},
process: { argv: [], env: { LH_DEV_URL: "http://127.0.0.1:9000" }, platform: "darwin", on() {} },
__dirname: path.dirname(mainFile), URL,
setInterval: (callback: () => void, delay: number) => intervals.push({ callback, delay }),
setTimeout() {},
fixtureChild: { kill: (signal: string) => order.push(signal) },
});
runInContext(mainSource, context, { filename: mainFile });
const run = (code: string) => runInContext(code, context);
return {
run, updater, windows, intervals, handles, settingsReads, order, app,
setSettings: (value: unknown) => { settings = JSON.stringify(value); },
setSettingsFailure: (value: string | Error) => { settings = value; },
setEnumerationFailure: (value: boolean) => { enumerationFails = value; },
window: () => new FakeWindow(),
state: () => JSON.parse(JSON.stringify(run("updateState"))),
check: async (manual = false) => { run(`checkForUpdates({ manual: ${manual} })`); await Promise.resolve(); await Promise.resolve(); },
menuCheck: () => menu[0]!.submenu!.find(item => item.label === "Check for Updates…")!.click!(),
};
}
describe("automatic-update preference gate", () => {
it("explicit OFF skips automatic checks, but manual checks still run without reading the preference", async () => {
const f = fixture();
f.setSettings({ general: { autoUpdate: false } });
f.run("setupAutoUpdater()");
await f.check();
assert.equal(f.updater.checks, 0);
assert.deepEqual(f.settingsReads, ["/fixture/custom-user-data-dev/data/settings.json"]);
await f.check(true);
assert.equal(f.updater.checks, 1);
assert.equal(f.settingsReads.length, 1);
assert.equal(f.updater.autoDownload, true);
assert.equal(f.updater.autoInstallOnAppQuit, true);
});
for (const [label, setting] of [
["ON", { general: { autoUpdate: true } }], ["absent field", { general: {} }],
["absent section", { version: 1 }], ["null", null], ["non-boolean", { general: { autoUpdate: "false" } }],
] as const) {
it(`${label} keeps automatic checks enabled`, async () => {
const f = fixture(); f.setSettings(setting); f.run("setupAutoUpdater()"); await f.check();
assert.equal(f.updater.checks, 1);
});
}
for (const [label, failure] of [["missing", new Error("ENOENT")], ["unreadable", new Error("EACCES")], ["corrupt", "{nope"]] as const) {
it(`${label} settings keep automatic checks enabled`, async () => {
const f = fixture(); f.setSettingsFailure(failure); f.run("setupAutoUpdater()"); await f.check();
assert.equal(f.updater.checks, 1);
});
}
it("re-reads the setting on each check, without triggering a check merely when saved", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.setSettings({ general: { autoUpdate: false } }); await f.check();
f.setSettings({ general: { autoUpdate: true } });
assert.equal(f.updater.checks, 0);
await f.check();
f.setSettings({ general: { autoUpdate: false } }); await f.check();
assert.equal(f.updater.checks, 1);
assert.equal(f.settingsReads.length, 3);
});
for (const label of ["unpackaged", "missing updater"] as const) {
it(`${label} stays unavailable, not a saved OFF preference`, async () => {
const f = fixture({ packaged: label !== "unpackaged", updaterMissing: label === "missing updater" });
f.run("setupAutoUpdater()"); await f.check(true); await f.check();
assert.equal(f.updater.checks, 0);
assert.equal(f.settingsReads.length, 0);
assert.equal(f.state().status, "disabled");
});
}
});
describe("status observation never controls the updater", () => {
it("reports events, but a staged update stays visible without suppressing future checks", async () => {
const f = fixture(); const win = f.window(); f.run("setupAutoUpdater()");
f.updater.emit("checking-for-update"); assert.equal(f.state().status, "checking");
f.updater.emit("update-available", { version: "0.3.8" });
assert.equal(f.state().status, "downloading"); assert.equal(f.state().version, "0.3.8");
f.updater.emit("update-downloaded", { version: "0.3.8" });
const ready = f.state();
await f.check();
f.updater.emit("error", new Error("failure with sensitive diagnostic details"));
assert.equal(f.updater.checks, 1);
assert.deepEqual(f.state(), ready);
f.updater.emit("update-downloaded", { version: "0.3.9" });
assert.equal(f.state().version, "0.3.9");
assert.ok(f.state().revision > ready.revision);
assert.equal(win.webContents.messages.at(-1)!.channel, "lh:update-status");
});
for (const status of ["checking", "downloading", "error", "disabled", "downloaded"]) {
it(`a ${status} display state does not block a future automatic check`, async () => {
const f = fixture(); f.run(`setupAutoUpdater(); reportUpdateStatus("${status}")`); await f.check();
assert.equal(f.updater.checks, 1);
});
}
it("check failures settle to a generic error and later checks still run", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.updater.failure = new Error("private endpoint/diagnostic detail"); await f.check();
assert.equal(f.state().status, "error");
assert.ok(!JSON.stringify(f.state()).includes("private"));
f.updater.failure = null; await f.check();
assert.equal(f.updater.checks, 2); assert.equal(f.state().status, "up-to-date");
});
it("a declined check makes no up-to-date claim", async () => {
const f = fixture(); f.updater.result = null; f.run("setupAutoUpdater()"); await f.check(true);
assert.equal(f.state().status, "idle");
});
it("window enumeration/send failures do not break checks or the next window's notification", async () => {
const f = fixture(); const bad = f.window(); bad.webContents.fails = true; const good = f.window();
f.run("setupAutoUpdater()"); await f.check();
assert.equal(good.webContents.messages.at(-1)!.channel, "lh:update-status");
f.setEnumerationFailure(true); await f.check();
f.setEnumerationFailure(false); await f.check();
assert.equal(f.updater.checks, 3);
});
it("OFF does not cancel in-flight events or alter staging/install-on-quit; Restart kills server first", async () => {
const f = fixture(); f.run("setupAutoUpdater()");
f.updater.emit("update-available", { version: "0.3.8" });
f.setSettings({ general: { autoUpdate: false } });
f.updater.emit("update-downloaded", { version: "0.3.8" }); await f.check();
assert.equal(f.state().status, "downloaded"); assert.equal(f.updater.checks, 0);
assert.equal(f.updater.autoDownload, true); assert.equal(f.updater.autoInstallOnAppQuit, true);
f.run("serverProcess = fixtureChild; installUpdateNow()");
assert.deepEqual(f.order, ["SIGTERM", "install"]);
});
});
describe("startup, timer, trusted IPC and menu wiring", () => {
it("starts and retains the 24h timer without any Settings subscription, checking current preference each time", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } });
await f.run("main()");
assert.equal(f.updater.checks, 0);
assert.equal(f.intervals.length, 1); assert.equal(f.intervals[0]!.delay, 24 * 60 * 60 * 1000);
f.setSettings({ general: { autoUpdate: true } }); f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 1);
f.windows.length = 0; f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 2, "checks also work with every window closed");
});
it("ON/default boot checks immediately and status never disables the scheduled check", async () => {
const f = fixture(); f.setSettings({ version: 1 }); await f.run("main()");
assert.equal(f.updater.checks, 1);
f.run('reportUpdateStatus("downloading", "0.3.8")'); f.intervals[0]!.callback(); await Promise.resolve();
assert.equal(f.updater.checks, 2);
});
it("snapshot/manual IPC accepts only an owned main frame and snapshot reads never check", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
const sender = f.windows[0]!.webContents;
const event = { sender, senderFrame: sender.mainFrame };
const get = f.handles.get("lh:update:get")!; const check = f.handles.get("lh:update:check")!;
assert.equal((get(event) as { status: string }).status, "idle"); assert.equal(f.updater.checks, 0);
check(event); assert.equal(f.updater.checks, 1);
for (const bad of [
{ sender: {}, senderFrame: sender.mainFrame },
{ sender, senderFrame: { url: sender.mainFrame.url } },
]) {
assert.throws(() => get(bad), /Untrusted/); assert.throws(() => check(bad), /Untrusted/);
}
sender.mainFrame.url = "https://untrusted.invalid/";
assert.throws(() => check(event), /Untrusted/);
assert.equal(f.updater.checks, 1);
});
it("menu restores/focuses a minimized window, opens General, and manually checks while OFF", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
const win = f.windows[0]!; win.webContents.loading = false; win.minimized = true;
f.menuCheck();
assert.equal(win.minimized, false); assert.equal(win.focused, true);
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings" && message.value === "general"));
assert.equal(f.updater.checks, 1);
});
it("menu reopens a window and delivers General after load; loading existing windows are covered too", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
f.windows.length = 0; f.menuCheck();
assert.equal(f.windows.length, 1); assert.equal(f.updater.checks, 1);
const win = f.windows[0]!;
assert.ok(!win.webContents.messages.some(message => message.channel === "lh:open-settings"));
win.webContents.emit("did-finish-load");
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings"));
f.menuCheck(); win.webContents.emit("did-finish-load");
assert.equal(win.webContents.messages.filter(message => message.channel === "lh:open-settings").length, 2);
});
it("menu navigation failure does not prevent its manual check", async () => {
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
f.windows[0]!.webContents.fails = true; f.windows[0]!.webContents.loading = false;
f.menuCheck(); assert.equal(f.updater.checks, 1);
});
});
+66 -5
View File
@@ -1,6 +1,9 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx";
import { parseModelsRefusal, parseProviderKeyRefusal, retryFailedResponse } from "../components/chat.tsx";
import { SessionModel } from "../components/session-core.ts";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { newUserMessage, type LhMessage } from "../src/core/native-messages.ts";
import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx";
import {
isModelsRefusal,
@@ -39,6 +42,64 @@ describe("parseModelsRefusal — the send refusal detector", () => {
});
});
describe("Retry owns no composer recovery draft", () => {
for (const envelope of [providerKeyMissing("openrouter", ["strong"]), modelsNotConfigured(["strong"])]) {
it(`accepted A / failed model / unsent B / Retry ${envelope.code} retires only A's fallback`, async () => {
const durable: LhMessage[] = [
newUserMessage("Accepted text A"),
{ ...fauxAssistantMessage("Partial reply", { stopReason: "error", errorMessage: "Model failed" }), id: "failed-A" },
];
const submittedDraft: { current: string | null } = { current: "Accepted text A" };
let composer = "Unsent text B";
const body = JSON.stringify(envelope);
const model = new SessionModel({
chatId: "fixture", initialMessages: durable,
fetch: async (_url, init) => {
assert.equal(submittedDraft.current, null, "retire A before Retry can reach a refusal");
assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" });
return new Response(body, { status: 503 });
},
});
let request!: ReturnType<SessionModel["send"]>;
retryFailedResponse((input) => request = model.send(input), submittedDraft);
await request;
assert.equal(model.getState().error?.message, body, "503 stays raw for existing setup/picker routing");
assert.ok(parseModelsRefusal(body) || parseProviderKeyRefusal(body));
// The existing refusal effect restores only an owned submitted
// draft. Retry must leave this slot empty, not replace B with A.
if (submittedDraft.current !== null) composer = submittedDraft.current;
assert.equal(composer, "Unsent text B");
assert.deepEqual(model.getState().nativeMessages, durable, "Retry adds no optimistic learner message");
});
}
it("does not clear a later ordinary send's recovery draft when Retry settles", async () => {
const submittedDraft: { current: string | null } = { current: "Accepted text A" };
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
retryFailedResponse(async () => pending, submittedDraft);
assert.equal(submittedDraft.current, null);
submittedDraft.current = "Later ordinary text C";
finish();
await pending;
await Promise.resolve();
assert.equal(submittedDraft.current, "Later ordinary text C", "no asynchronous cleanup may retire a newer owner");
});
it("ordinary text refusals still keep their submitted draft for recovery", async () => {
const submittedDraft: { current: string | null } = { current: "Ordinary text C" };
const body = JSON.stringify(providerKeyMissing("openrouter", ["strong"]));
const model = new SessionModel({
chatId: "fixture", initialMessages: [],
fetch: async () => new Response(body, { status: 503 }),
});
await model.send({ type: "text", text: submittedDraft.current! });
assert.equal(submittedDraft.current, "Ordinary text C");
assert.equal(model.getState().error?.message, body);
assert.equal(parseProviderKeyRefusal(model.getState().error?.message), true);
});
});
describe("isModelsRefusal — the job-record matcher", () => {
it("matches the runner's composed wording, not real failures", () => {
assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true);
@@ -123,9 +184,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
assert.deepEqual(pre, {});
});
it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => {
it("a disconnected saved identity still prefills without selectable metadata", () => {
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
assert.deepEqual(pre, {});
assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } });
});
it("provider collisions never cross-pollute (same id, different provider)", () => {
@@ -158,9 +219,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", (
});
});
describe("setupStage — unchanged by the BYOK wave", () => {
describe("setupStage — unknown is not keyless", () => {
it("keys first (owner ordering), pickers when a provider is keyed", () => {
assert.equal(setupStage(null), "keys");
assert.equal(setupStage(null), "loading");
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
});
+104 -4
View File
@@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts";
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
import { effectiveSettings } from "../src/server/stores.ts";
import { isTurnRunning } from "../src/server/turn-liveness.ts";
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { deliverJobResult, registerLiveAgent, setTurnRunner, steerLiveChat, unregisterLiveAgent } from "../src/server/jobs.ts";
import { SessionModel } from "../components/session-core.ts";
import { buildSubagentSteer } from "../src/core/contracts.ts";
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult, newUserMessage } from "../src/core/native-messages.ts";
import { projectView } from "../src/core/view.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
@@ -46,6 +47,8 @@ import type { LhMessage } from "../src/core/native-messages.ts";
// store imports already resolved under).
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
const steerRoute = await import("../app/api/chats/[id]/steer/route.ts");
const streamRoute = await import("../app/api/chats/[id]/stream/route.ts");
const recordRoute = await import("../app/api/chats/[id]/route.ts");
import { PATHS } from "../config.ts";
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
@@ -519,7 +522,7 @@ describe("teacher chat flow (scripted model)", () => {
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" });
jobIdMid = job.id;
// The runner stores the result BEFORE delivery (finalizeJob reads it).
jobStore.updateJob(job.id, { result: { brief: "Mid-run brief.", sources: [] } });
jobStore.updateJob(job.id, { status: "done", result: { brief: "Mid-run brief.", sources: [] }, delivery: { kind: "result", status: "pending" } });
const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] });
const delivery = deliverJobResult(steer);
await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off
@@ -531,7 +534,7 @@ describe("teacher chat flow (scripted model)", () => {
release2();
await drain(await res);
await delivery; // resolves at settleSteers (the run persisted the marker)
assert.equal(jobFlippedMidTurn, "done", "the job flipped at the marker's message_end — WHILE the turn still streamed");
assert.equal(jobFlippedMidTurn, "done", "worker completion is independent of the streaming teacher turn");
// Record shape: the marker sits BETWEEN completed turn units — after
// the toolResult it must never split, adjacent to it.
@@ -845,6 +848,103 @@ describe("chat store", () => {
});
});
describe("canonical persistence failures", () => {
it("refuses a failed input save without building an agent or leaving a live turn", async () => {
const { engine } = scriptedTeacher(defaultLesson);
const chat = createConfiguredChat("Test: failed answer write-ahead");
createdChats.push(chat.id);
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
const before = chatStore.getChat(chat.id)!.messages;
const save = chatStore.saveMessages;
let builds = 0;
chatStore.saveMessages = () => { throw new Error("synthetic write-ahead failure"); };
try {
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), (opts) => {
builds++;
return agentFactory(engine)!(opts);
});
const running = runningTurn(chat.id);
if (running) await new Response(running.subscribe()).text();
assert.equal(res.status, 500);
const client = new SessionModel({ chatId: chat.id, initialMessages: before, fetch: async () => res.clone() });
await client.submitAnswer("quiz", "call-1", gradedAnswer(QUIZ_INPUT, ["Mercury"]));
assert.equal(client.getState().error?.message, "Your input could not be saved. Please try again.");
assert.equal(await res.text(), "Your input could not be saved. Please try again.");
assert.equal(builds, 0);
assert.equal(isTurnRunning(chat.id), false);
assert.equal(runningTurn(chat.id), undefined);
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before);
} finally { chatStore.saveMessages = save; }
});
it("reports a failed reply save before DONE while keeping the accepted answer durable", async () => {
const { engine } = scriptedTeacher(defaultLesson);
const chat = createConfiguredChat("Test: failed reply persistence");
createdChats.push(chat.id);
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
const save = chatStore.saveMessages;
let writes = 0;
chatStore.saveMessages = (id, messages) => {
if (++writes === 2) throw new Error("synthetic reply save failure");
return save(id, messages);
};
try {
const body = await drainTurn(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine));
const errors = eventsOf(body).filter(e => e.type === "error");
assert.equal(errors.length, 1);
assert.equal(errors[0]!.errorText, "The reply could not be saved.");
assert.ok(body.indexOf("The reply could not be saved.") < body.indexOf("[DONE]"));
assert.equal(isTurnRunning(chat.id), false);
assert.equal(runningTurn(chat.id), undefined);
assert.equal(chatStore.getChat(chat.id)!.messages.at(-1)?.role, "toolResult");
// The normal submitter may not reach GET /stream until AFTER the
// failing turn has left the live registry. It must still see it.
const late = await streamRoute.GET(new Request("http://test/stream"), { params: Promise.resolve({ id: chat.id }) });
assert.equal(eventsOf(await late.text()).find(e => e.type === "error")?.errorText, "The reply could not be saved.");
// An already-open idle view can miss both the start and the wire
// error, then learn of settlement only via a record synchronization.
const snapshot = await (await recordRoute.GET(new Request("http://test/record"), { params: Promise.resolve({ id: chat.id }) })).json();
assert.equal(needsContinuation(snapshot.messages), true, "the durable answer alone looks like an owed continuation");
let automaticPosts = 0;
const missedView = new SessionModel({ chatId: chat.id, initialMessages: [], fetch: async () => {
automaticPosts++;
return new Response("unexpected automatic retry", { status: 500 });
} });
missedView.setMessages(snapshot.messages, snapshot.turnError);
await missedView.continueIfOwed(snapshot.messages);
assert.equal(automaticPosts, 0, "the record notice must block replacement even without a stream attachment");
assert.equal(missedView.getState().error?.message, "The reply could not be saved.");
} finally { chatStore.saveMessages = save; }
});
it("does not redeliver a persisted learner steer after usage bookkeeping fails", async () => {
const { engine } = scriptedTeacher(() => [() => fauxAssistantMessage([fauxText("Synthetic response")])]);
const chat = createConfiguredChat("Test: post-save bookkeeping failure");
createdChats.push(chat.id);
const steer = newUserMessage("Synthetic learner steer");
const appendUsage = chatStore.appendTurnUsage;
chatStore.appendTurnUsage = () => { throw new Error("synthetic usage write failure"); };
try {
const body = await drainTurn(post(chat.id, { type: "text", text: "go" }), opts => {
const agent = createTeacherAgent(undefined, { engine, messages: opts.messages, onUsage: opts.onUsage });
agent.subscribe(event => {
if (event.type === "agent_start") assert.equal(steerLiveChat(chat.id, steer), true);
});
return agent;
});
assert.equal(eventsOf(body).find(e => e.type === "error")?.errorText, "An error occurred.");
assert.ok(chatStore.getChat(chat.id)!.messages.some(m => m.id === steer.id), "the steer was saved before bookkeeping failed");
const delivered: LhMessage[] = [];
const next = { steer: (message: LhMessage) => delivered.push(message), state: { model: { provider: "faux" } } } as unknown as Parameters<typeof registerLiveAgent>[1];
registerLiveAgent(chat.id, next);
assert.deepEqual(delivered, [], "an already-saved learner steer must not reach another agent");
} finally {
chatStore.appendTurnUsage = appendUsage;
unregisterLiveAgent(chat.id);
}
});
});
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
const { engine } = scriptedTeacher(defaultLesson);
+75 -14
View File
@@ -6,6 +6,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
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");
@@ -445,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" });
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
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 () => {
@@ -581,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);
@@ -594,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();
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
// 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();
for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
completion = deferred<ChatGPTStatus>();
await restarted.service.login();
const response = await restarted.callback(); assert.equal(response.status, 200); await response.text();
const status = await completedWithin(completion.promise, "verified sign-in after zero-account restart");
assert.equal(status.pending, false); assert.equal(status.error, null);
assert.notEqual(status.activeId, null); assert.equal(status.accounts[0]!.connected, true);
assert.equal(restarted.saved.allowBackgroundUse, false);
await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true);
} finally { restarted.service.close(); }
} finally { f.service.close(); }
} finally { releaseToken.resolve(); f.service.close(); }
});
test("private leases bind active account, authority revision and raw visible model membership", async () => {
+3 -2
View File
@@ -43,7 +43,7 @@ for (const revokedAfterQueue of [false, true]) {
stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
const result = { brief: "Scratch completed job result", sources: [] };
stores.jobStore.updateJob(job.id, { status: "running", result });
stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } });
const marker = buildSubagentSteer(job, result);
const contexts: string[] = [];
const native = piModels().getModel("openai", "gpt-5")!;
@@ -83,7 +83,8 @@ for (const revokedAfterQueue of [false, true]) {
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(wakes, 0, "no background spin or spend while consent is absent");
assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral");
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending", "delivery ownership must not be finalized or failed by deferral");
assert.equal(stores.jobStore.getJob(job.id)!.status, "done", "worker completion is independent of consent deferral");
assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
connection(true);
await delivered;
+39 -7
View File
@@ -12,6 +12,7 @@ import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx";
import { KeysSection } from "../components/settings/keys-section.tsx";
import { ModelControl } from "../components/model-picker.tsx";
import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts";
import { completedWithin } from "./completion.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
@@ -23,7 +24,7 @@ const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnect
const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.";
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
function deferred<T>() { let resolve!: (v: T) => void; let reject!: (error: unknown) => void; const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; }
function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
let status = initial;
const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 };
@@ -494,26 +495,57 @@ test("canceled attempt's late reply cannot release a newer pending attempt or ap
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => {
for (const holdSave of [false, true]) test(`real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence${holdSave ? " (completion held beyond old wait budget)" : ""}`, async () => {
const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" };
let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>();
const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => {
const consentCompleted = deferred<ChatGPTStatus>(), saveEntered = deferred<void>(), releaseSave = deferred<void>();
let saved: any = null, authorize!: URL, tokenCalls = 0, completionArmed = false; const listeners = new Set<() => void>();
const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) {
if (holdSave && s.allowBackgroundUse === false) { saveEntered.resolve(); await releaseSave.promise; }
saved = structuredClone(s);
} }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: ({ status }: { status: ChatGPTStatus }) => {
if (completionArmed && !status.pending && status.error) consentCompleted.reject(new Error(`Fixture sign-in failed: ${status.error}`));
for (const cb of listeners) cb();
}, fetch: async (url: string, init: RequestInit) => {
if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++;
const form = init.body as URLSearchParams;
const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey);
return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
} });
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => {
const reply = chatGPTControl({ op: "consent", enabled }, service);
void reply.then(consentCompleted.resolve, consentCompleted.reject);
return reply;
}, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
try {
await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0);
await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString();
await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); });
await act(async () => {
completionArmed = true;
// Callback acknowledgment is not verified login, nor is login completion
// the subsequent consent save. Wait for the actual control reply.
const waiting = completedWithin(consentCompleted.promise, "verified sign-in and global draft persistence");
let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; });
const response = await originalFetch(callback); assert.equal(response.status, 200); await response.text();
if (holdSave) {
await completedWithin(saveEntered.promise, "held global draft save");
// Regression only: the OLD loop returns with the save still held.
for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r));
assert.equal((await service.status()).pending, false, "verification finished before the consent save");
assert.equal(saved.allowBackgroundUse, undefined, "the old assertion would run before the global draft was saved");
assert.equal(settled, false, "the corrected wait must still await persistence");
releaseSave.resolve();
}
const status = await waiting;
assert.equal(status.error, null); assert.equal(status.pending, false);
assert.equal(status.allowBackgroundUse, false); assert.equal(status.accounts[0]!.connected, true);
});
assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined);
assert.equal(document.querySelector('[role="alert"]'), null);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…"));
} finally { await act(async () => service.close()); }
} finally { releaseSave.resolve(); await act(async () => service.close()); }
});
test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => {
+6 -2
View File
@@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts");
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
test("Next-style IPC is not desktop transport; web ownership works without delaying keyed catalogs", async () => {
const send = Object.getOwnPropertyDescriptor(process, "send");
const connected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
@@ -35,7 +35,11 @@ test("Next-style connected IPC is not desktop transport and never delays keyed A
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
assert.equal(chatGPTConnected(), false);
assert.deepEqual(chatGPTModels(), []);
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
if (!mode && !capability) {
const value = await chatGPTRequest<{ status: { available: boolean; accounts: unknown[] } }>("status");
assert.equal(value.status.available, true);
assert.deepEqual(value.status.accounts, []);
} else await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
}
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
+111 -1
View File
@@ -27,12 +27,14 @@ const {
ensureCompacted,
kickCompaction,
awaitCompaction,
compactionInFlight,
effectiveThresholdTokens,
projectedContextTokens,
} = await import("../src/server/compaction.ts");
import type { CompactionDeps } from "../src/server/compaction.ts";
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
const { subscribe } = await import("../src/core/events.ts");
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
import type { LhMessage } from "../src/core/index.ts";
after(() => {
@@ -167,12 +169,120 @@ describe("the compaction service", () => {
await ensureCompacted(deps, chatId);
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once");
const record = chatStore.getChat(chatId)!;
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
});
it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => {
const chat = chatStore.createChat("repeated-cycle fixture");
chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8)));
let calls = 0;
const deps: CompactionDeps = {
...depsOf(chat.id, 150_000),
summarize: async (prompt) => {
assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1);
if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward");
return {
summary: `SUMMARY-${++calls}.`,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
};
},
};
for (let cycle = 1; cycle <= 10; cycle++) {
const messages = [...chatStore.getChat(chat.id)!.messages];
if (cycle > 1) messages.push(big(`growth-${cycle}`, 8));
// Controlled provider readings trigger compaction while the visible
// retained tail advances slowly enough to contain older markers.
messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage);
chatStore.saveMessages(chat.id, messages);
await ensureCompacted(deps, chat.id);
assert.equal(calls, cycle);
const record = chatStore.getChat(chat.id)!;
assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored");
assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle);
const wire = JSON.stringify(toLlmMessages(record.messages));
assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`);
assert.ok(wire.includes(`SUMMARY-${cycle}.`));
assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings");
}
});
it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => {
const chat = chatStore.createChat("summary span fixture");
const kept = big("previously retained");
const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" };
const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 };
const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")];
chatStore.saveMessages(chat.id, messages);
let seenPrompt = "";
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
seenPrompt = prompt;
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
} }, chat.id);
assert.ok(seenPrompt.includes("previously retained"));
assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span");
assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY"));
assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1);
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
});
it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => {
const chat = chatStore.createChat("legacy summary fixture");
const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" });
const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")];
chatStore.saveMessages(chat.id, messages);
let seenPrompt = "";
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
seenPrompt = prompt;
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
} }, chat.id);
assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1);
assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff");
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
});
it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => {
const chatId = overThresholdChat("incomplete summary fixture");
await ensureCompacted(depsOf(chatId, 40_000), chatId);
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]);
const faux = createFauxCore({});
const { summarize: _injected, ...base } = depsOf(chatId, 40_000);
const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } };
const before = chatStore.getChat(chatId)!;
assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too");
const events: string[] = [];
const off = subscribe((event) => {
const { chatId: id, running } = event as { chatId?: string; running?: boolean };
if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`);
});
// Exercise the actual zero-tool summarizer with an offline provider;
// NODE_ENV=test otherwise deliberately skips non-injected summaries.
const env: Record<string, string | undefined> = process.env;
const originalNodeEnv = env.NODE_ENV;
env.NODE_ENV = "development";
try {
faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]);
await ensureCompacted(deps, chatId);
assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint");
assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate");
assert.deepEqual(events, ["true", "false"]);
faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]);
await ensureCompacted(deps, chatId);
const record = chatStore.getChat(chatId)!;
assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY"));
assert.deepEqual(record.messages.slice(0, -1), before.messages);
assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY"));
} finally {
if (originalNodeEnv === undefined) delete env.NODE_ENV;
else env.NODE_ENV = originalNodeEnv;
off();
}
});
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
+44
View File
@@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => {
assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once");
});
it("omits nine superseded summaries inside the retained tail without changing stored history", () => {
const kept = newUserMessage("retained exchange");
const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" };
const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details));
const fresh = newCompactionMarker("LATEST SUMMARY", details);
const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh];
const original = JSON.stringify(messages);
const out = toLlmMessages(messages);
const flat = JSON.stringify(out);
assert.equal(out.length, 3, "latest summary + retained exchange + latest reply");
assert.ok(flat.includes("LATEST SUMMARY"));
assert.ok(!flat.includes("SUPERSEDED-"));
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
assert.equal(JSON.stringify(messages), original, "projection never rewrites the record");
});
it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => {
const kept = [assistantWithCall("a1", "call_1")];
const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" });
@@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => {
assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)");
});
it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => {
const kept = newUserMessage("retained exchange");
const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" });
const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" });
const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh];
const original = JSON.stringify(messages);
const out = toLlmMessages(messages);
assert.equal(out.length, messages.length);
assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff");
const flat = JSON.stringify(out);
assert.ok(flat.includes("OLD SUMMARY"));
assert.ok(flat.includes("LATEST SUMMARY"));
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2);
assert.equal(JSON.stringify(messages), original);
});
it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => {
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
const m1 = newUserMessage("x");
@@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize");
});
it("preserves legacy summary text when the caller has no separately supplied prior summary", () => {
const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" });
assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY"));
});
it("the prompt demands the four sections in order and carries the plan tree verbatim", () => {
const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" });
const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map(
@@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
const record = (): LhMessage[] => [
{ id: "old", role: "user", content: "dropped history", timestamp: 1 },
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 },
newCompactionMarker("SUPERSEDED SUMMARY", {
firstKeptMessageId: "kept",
tokensBefore: 98,
at: new Date(0).toISOString(),
}),
newCompactionMarker("SUMMARY", {
firstKeptMessageId: "kept",
tokensBefore: 99,
@@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
: ((m.content as { text?: string }[])[0]?.text ?? "");
assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail"));
assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history"));
assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY"));
assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1);
});
it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => {
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { completedWithin } from "./completion.ts";
test("fixture completion wait returns its result and preserves its actual error", async () => {
assert.equal(await completedWithin(Promise.resolve("saved"), "fixture save"), "saved");
const failure = new Error("fixture persistence failed");
await assert.rejects(completedWithin(Promise.reject(failure), "fixture save"), (error) => error === failure);
});
test("fixture completion wait fails clearly if completion never arrives", async () => {
await assert.rejects(
completedWithin(new Promise<never>(() => {}), "verified fixture sign-in", 0),
/Timed out after 0ms waiting for verified fixture sign-in/,
);
});
+15
View File
@@ -0,0 +1,15 @@
/** Bound an actual fixture completion signal, not a count of event-loop
* turns. Always clear the watchdog, and preserve real fixture failures. */
export async function completedWithin<T>(completion: Promise<T>, description: string, timeoutMs = 10_000): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
completion,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { repairMathForDisplay, restoreMathEscapes, transformMathDelimiters } from "../src/core/math-delims.ts";
const math = String.raw;
test("document paragraphs and display framing recover without interpreting n-commands as newlines", () => {
const source = math`First.\n\nSecond: $\nabla f\neq0$.\n\n$$\n\frac{1}{2}\n$$`;
assert.equal(repairMathForDisplay(source), "First.\n\nSecond: " + math`$\nabla f\neq0$.` + "\n\n$$\n" + math`\frac{1}{2}` + "\n$$");
});
test("literal document escapes fused to unknown math commands use document context", () => {
const source = math`Jacobian:\n\n$$\nJ=\begin{pmatrix}1&2\\3&4\end{pmatrix}\n$$`;
assert.equal(repairMathForDisplay(source), "Jacobian:\n\n$$\n" + math`J=\begin{pmatrix}1&2\\3&4\end{pmatrix}` + "\n$$");
});
test("encoded environment profiles decode row separators by their factor, not to one slash", () => {
for (const [source, expected] of [
[math`$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`],
[math`$\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}$`, math`$\begin{pmatrix}1&2\\3&4\end{pmatrix}$`],
[math`$\\begin{pmatrix}a&b\\\\\\nabla f&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\\nabla f&d\end{pmatrix}$`],
[math`$\\substack{a\\\\beta}$`, math`$\substack{a\\beta}$`],
] as const) {
assert.equal(repairMathForDisplay(source), expected);
assert.equal(repairMathForDisplay(expected), expected);
}
});
test("native substack rows and literal document escapes in genuine comments remain literal", () => {
const source = math`$\substack{a\\beta}$`;
assert.equal(repairMathForDisplay(source), source);
const comment = "$x % " + math`literal \n\n` + "\ny$";
assert.equal(repairMathForDisplay(comment), comment);
});
test("inline lost prefixes recover only known full commands; display newlines remain whitespace", () => {
assert.equal(repairMathForDisplay("$\nabla f$"), math`$\nabla f$`);
assert.equal(repairMathForDisplay("$x+\ny$"), "$x+\ny$");
assert.equal(repairMathForDisplay("$x+\nu$"), math`$x+\nu$`); // approved typical-inline-authoring policy
assert.equal(repairMathForDisplay("$$\nabla f\n$$"), "$$\nabla f\n$$"); // display source whitespace
});
test("physical display newlines and genuine row breaks/comments survive", () => {
const source = "$$\n" + math`\begin{aligned}a&=b\\% row comment` + "\nc&=d\end{aligned}\n$$";
assert.equal(repairMathForDisplay(source), source);
});
test("proper nu survives every context, including fields with document escapes", () => {
for (const source of [math`$\nu$`, "$$\n" + math`\nu` + "\n$$", math`$\nabla f+\nu+\neq$`]) {
assert.equal(repairMathForDisplay(source), source);
}
assert.equal(repairMathForDisplay(math`First.\n\n$\nu$`), "First.\n\n" + math`$\nu$`);
assert.equal(repairMathForDisplay("$$\nu\n$$"), "$$\nu\n$$"); // LF + variable u in display
});
test("tabs recover known commands including to, not arbitrary whitespace", () => {
assert.equal(repairMathForDisplay("$$\nx\t+\ty\n$$"), "$$\nx\t+\ty\n$$");
assert.equal(repairMathForDisplay("$a\to b$"), math`$a\to b$`);
assert.equal(repairMathForDisplay("$$\na\to b\n$$"), "$$\n" + math`a\to b` + "\n$$");
assert.equal(repairMathForDisplay("$$\r\nx+1\r\n$$"), "$$\r\nx+1\r\n$$");
});
test("Markdown containers protect literal code and do not swallow following content", () => {
for (const source of [
"> ~~~text\n> " + math`$\\alpha$ \n\n` + "\n> ~~~",
"> > ~~~text\n> > " + math`$\\alpha$` + "\n> > ~~~",
"- ~~~text\n " + math`$\\alpha$` + "\n ~~~",
"> ~~~text\n> " + math`$\\alpha$` + "\n\n~~~text\n" + math`$\\beta$` + "\n~~~",
]) {
assert.equal(repairMathForDisplay(source), source);
assert.equal(transformMathDelimiters(source), source);
assert.equal(repairMathForDisplay(source + "\n\n" + math`$\\gamma$`), source + "\n\n" + math`$\gamma$`);
}
});
test("numeric percentages recover, including percentages inside text groups", () => {
for (const [source, expected] of [
[math`$60\\%$`, math`$60\%$`],
[math`$50\\%+10\\%=60\\%$`, math`$50\%+10\%=60\%$`],
[math`$\text{ (50\\%)}$`, math`$\text{ (50\%)}$`],
] as const) assert.equal(repairMathForDisplay(source), expected);
});
test("numeric row breaks followed by genuine comments are not percentage escapes", () => {
const source = "$60\\\\% explanation of this row\nx$";
assert.equal(repairMathForDisplay(source), source);
const ambiguous = math`$x\\%$`;
assert.equal(repairMathForDisplay(ambiguous), ambiguous);
});
test("inline and fenced code retain all bytes, including legacy delimiters and actual control characters", () => {
const code = "```text\n\\(x\nabla\\)\n\\n\\n\n```\n" + "`" + math`\n\n $60\\%$ \(x\)` + "`";
assert.equal(repairMathForDisplay(code), code);
assert.equal(restoreMathEscapes(code), code);
});
test("math matching cannot consume a blank paragraph or a code span", () => {
const source = "Price $5.\n\nThen $x$ and `code` remains.";
assert.equal(repairMathForDisplay(source), source);
assert.equal(repairMathForDisplay("$not `code` math$"), "$not `code` math$");
});
test("Windows paths and correctly written n-commands are not document escapes", () => {
const source = math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.\n\nNext paragraph.`;
assert.equal(repairMathForDisplay(source), math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.` + "\n\nNext paragraph.");
});
test("declared n-prefixed macros are commands, not document escapes", () => {
const source = math`$\newcommand{\nThing}{x}\nThing$.\n\nNext.`;
assert.equal(repairMathForDisplay(source), math`$\newcommand{\nThing}{x}\nThing$.` + "\n\nNext.");
});
test("partial recovery cannot turn otherwise parseable TeX into a missing-argument error", () => {
const source = math`$x\\frac{a}$`;
assert.equal(repairMathForDisplay(source), source);
});
test("legacy compatibility and recovery share code barriers, including tilde and indentation", () => {
for (const source of [" " + math`\(x\) \n\n`, "\\[\n~~~tex\n" + math`\n\n \(x\)` + "\n\\]\n~~~"]) {
assert.equal(repairMathForDisplay(source), source);
assert.equal(transformMathDelimiters(source), source);
}
const comment = "$60% " + math`\n\n` + "\nx$";
assert.equal(repairMathForDisplay(comment), comment);
});
test("recovery converges after one pass, including multiple debris characters", () => {
for (const source of ["$\forall x,\theta+\to$", math`First.\n\n$$\nJ=\\frac{1}{2}+60\\%\n$$`,
math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`, "```text\n\\(\nabla\\)\n```", "$\nabla f$",
]) {
const once = repairMathForDisplay(source);
assert.equal(repairMathForDisplay(once), once);
}
});
+12 -21
View File
@@ -6,7 +6,7 @@
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
* reducer (reduceDemoBridgeState)
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
* failure notes, inline sanitized svg
* failure notes, SVG image documents
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
* a fix iteration, untested-final verification, never-clean failure,
* no-Chrome failure)
@@ -40,7 +40,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";
@@ -274,14 +274,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 +325,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 +342,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 +373,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 +590,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 +839,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);
+57 -5
View File
@@ -15,7 +15,7 @@ const mainRequire = createRequire(mainFile);
/** Decline the single-instance lock so normal boot never spawns a server.
* Then call the REAL createWindow with a stub Electron API and inspect its
* constructor options. No rewritten copy of the production logic. */
function windowOptions(platform: string) {
function windowOptions(platform: string, env: Record<string, string> = {}) {
class FakeWindow {
constructor(readonly options: unknown) {}
webContents = { on() {}, setWindowOpenHandler() {} };
@@ -23,13 +23,13 @@ function windowOptions(platform: string) {
once() {}
}
const electron = {
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} },
app: { setName() {}, getPath: () => "/fixture/LearnHarness", setPath() {}, requestSingleInstanceLock: () => false, quit() {} },
BrowserWindow: FakeWindow,
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = {
require: (id: string) => id === "electron" ? electron : mainRequire(id),
process: { platform, argv: [], env: {}, on() {} },
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 context = {
@@ -72,18 +90,52 @@ describe("desktop window chrome", () => {
bridge = api;
},
},
ipcRenderer: {},
ipcRenderer: { on() {} },
}),
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions",
"chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "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"));
+322
View File
@@ -0,0 +1,322 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { useDictation, type Dictation } from "../components/dictation.tsx";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { useDictationLanguage } from "../components/dictation-preference.ts";
import { isDictationActive, useDictationActive } from "../components/dictation-state.ts";
import type { 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 = Socket.CONNECTING;
sent: unknown[] = [];
constructor(readonly url: string, readonly protocols: string[]) { super(); Socket.all.push(this); }
get language() { return new URL(this.url).searchParams.get("language"); }
send(data: unknown) { this.sent.push(data); }
close() { this.readyState = 3; }
open() { this.readyState = Socket.OPEN; this.dispatchEvent(new Event("open")); }
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 { port = {}; connect() {} disconnect() {} }
const globals = globalThis as Record<string, unknown>;
let restore: (() => void) | undefined;
let dispose: (() => Promise<void>) | undefined;
async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Response>) {
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
const state = {
record: { version: 1, dictation: { language } } as UserSettings,
nextRead: delayedRead,
nextWrite: undefined as Promise<Response> | undefined,
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
readFails: false,
reads: 0,
active: false,
captures: 0,
stops: 0,
appends: [] as string[],
};
Socket.all = [];
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: "", platform: "", 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 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/dictation");
assert.equal(init?.method, "PUT");
if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; }
state.record = { ...state.record, dictation: 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 useDictationLanguage>;
function Harness() {
latest = useDictation({ append: (text) => state.appends.push(text) });
setting = useDictationLanguage();
state.active = useDictationActive();
return null;
}
let root = createRoot(document.getElementById("root")!);
let firstMount = true;
const mount = async () => {
await act(async () => { root.render(<Harness />); });
// Each fixture is a new server record. Explicitly invalidate a prior
// fixture's window-lifetime cache, but do not refetch on remounts.
if (firstMount && setting.language !== undefined) {
await act(async () => { notifySettingsSaved("dictation"); });
}
firstMount = false;
};
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("waits for the saved language; reconnect preserves it and finals still flush exactly once", async () => {
const read = deferred<Response>();
const f = await fixture("pl", read.promise);
await f.mount();
await act(async () => { f.dictation.toggle(); });
assert.equal(f.dictation.phase, "connecting");
assert.equal(f.state.active, true, "the settings row locks while connecting");
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0, "must not open a default-English socket before settings arrive");
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
const first = Socket.all[0]!;
assert.equal(first.language, "pl");
await act(async () => { first.open(); first.final("Cześć"); });
assert.deepEqual(f.state.appends, []);
// A preference refresh during capture affects the next run only.
f.state.record.dictation = { language: "multi" };
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { first.end(1006); });
const second = Socket.all[1]!;
assert.equal(second.language, "pl");
assert.deepEqual(second.protocols, ["bearer", "test-token"]);
await act(async () => { second.open(); second.final("świecie"); });
await act(async () => { f.dictation.stop(); });
assert.equal(f.dictation.phase, "stopping");
assert.equal(f.state.active, true, "the settings row remains locked while stopping");
assert.equal(second.sent.at(-1), JSON.stringify({ type: "CloseStream" }));
await act(async () => { second.end(); });
assert.deepEqual(f.state.appends, ["Cześć świecie"]);
assert.equal(f.dictation.phase, "idle");
assert.equal(f.state.active, false);
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[2]!.language, "multi");
});
test("a failed save keeps the previous language, including a shortcut pressed while saving", async () => {
const f = await fixture();
await f.mount();
const write = deferred<Response>();
f.state.nextWrite = write.promise;
let saved!: Promise<boolean>;
await act(async () => { saved = f.setting.save("multi"); });
assert.equal(f.setting.saving, true);
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 0);
await act(async () => { write.resolve(Response.json({}, { status: 500 })); await saved; });
assert.equal(await saved, false);
assert.equal(f.setting.language, "pl");
assert.match(f.setting.error!, /Could not save/);
assert.equal(Socket.all[0]!.language, "pl");
});
test("confirmed choices stay cached across navigation; failed explicit refreshes block capture", async () => {
const f = await fixture();
await f.mount();
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
const reads = f.state.reads;
f.state.readFails = true;
await f.remount();
assert.equal(f.setting.language, "multi");
assert.equal(f.state.reads, reads, "navigation must not reread a confirmed preference");
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { f.dictation.toggle(); });
assert.match(f.dictation.error!, /Could not load/);
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
f.state.readFails = false;
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("a shortcut waits for a language save made by the separate settings control", async () => {
const f = await fixture();
await f.mount();
const write = deferred<Response>();
f.state.nextWrite = write.promise;
let saved!: Promise<boolean>;
await act(async () => { saved = f.setting.save("multi"); });
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 0);
f.state.record.dictation = { language: "multi" };
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; });
assert.equal(Socket.all[0]!.language, "multi");
});
test("a saved-language refresh gates capture even when an older choice is cached", async () => {
const f = await fixture();
await f.mount();
const read = deferred<Response>();
f.state.nextRead = read.promise;
f.state.record.dictation = { language: "multi" };
await act(async () => { notifySettingsSaved("dictation"); });
await act(async () => { f.dictation.toggle(); });
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("a stale settings 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("dictation"); });
await act(async () => { await f.setting.save("multi"); });
await act(async () => { read.resolve(Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); });
assert.equal(f.setting.language, "multi");
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("cancel while settings load does not open a microphone or socket later", async () => {
const read = deferred<Response>();
const f = await fixture("pl", read.promise);
await f.mount();
await act(async () => { f.dictation.toggle(); });
await act(async () => { f.dictation.cancel(); });
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
assert.equal(f.dictation.phase, "idle");
assert.equal(f.state.captures, 0);
assert.equal(Socket.all.length, 0);
});
test("reopening settings during a save shares its busy state and rejects a competing selection", async () => {
const f = await fixture();
await f.mount();
const write = deferred<Response>();
f.state.nextWrite = write.promise;
let first!: Promise<boolean>;
await act(async () => { first = f.setting.save("multi"); });
await f.remount();
assert.equal(f.setting.saving, true);
assert.equal(await f.setting.save("en"), false, "a different selection cannot inherit another write's success");
f.state.record.dictation = { language: "multi" };
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await first; });
assert.equal(f.setting.saving, false);
assert.equal(f.setting.language, "multi");
});
test("a confirmed save applies to capture even when subsequent settings GET requests would fail", async () => {
const f = await fixture();
await f.mount();
f.state.readFails = true;
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
assert.equal(f.state.record.dictation?.language, "multi");
await act(async () => { f.dictation.toggle(); });
assert.equal(Socket.all[0]!.language, "multi");
});
test("unmount during a pending language read prevents orphaned capture", async () => {
const read = deferred<Response>();
const f = await fixture("pl", read.promise);
await f.mount();
await act(async () => { f.dictation.toggle(); });
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 during recording stops resources and discards the departing page's buffered text", 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("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(); });
assert.equal(f.state.captures, 1);
await f.unmount();
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
assert.equal(f.state.stops, 1);
assert.equal(Socket.all.length, 0);
assert.equal(isDictationActive(), false);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { after, describe, it } from "node:test";
import { DICTATION_LANGUAGES, deepgramListenUrl, isDictationLanguage } from "../src/core/dictation.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { parseDictationSettingsBody } from "../src/server/settings-body.ts";
describe("Nova-3 dictation language contract", () => {
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length);
for (const code of ["en", "fr", "ru", "sv", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) {
assert.ok(isDictationLanguage(code), code);
}
// Xhosa and Zulu are documented for multi, not monolingual Nova-3.
for (const code of ["xh", "zu", "en-CA", "auto", "xx", "pl&model=other", null, 3]) {
assert.equal(isDictationLanguage(code), false, String(code));
}
const labels = DICTATION_LANGUAGES.slice(1).map((item) => item.label);
assert.deepEqual(labels, [...labels].sort((a, b) => a.localeCompare(b, "en")));
});
it("only language varies in the streaming URL; all tuned parameters survive", () => {
for (const { code: language } of DICTATION_LANGUAGES) {
const url = new URL(deepgramListenUrl(language));
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
assert.deepEqual(Object.fromEntries(url.searchParams), {
model: "nova-3", encoding: "linear16", sample_rate: "16000", channels: "1",
smart_format: "true", punctuate: "true", endpointing: "300", language,
});
}
});
it("old records default to English and explicit preferences resolve without a migration", () => {
assert.equal(resolveSettings(null).dictation.language, "en");
assert.equal(resolveSettings({ version: 1 }).dictation.language, "en");
for (const language of ["pl", "multi", "en-GB"] as const) {
const record = sanitizeUserSettings({ version: 1, dictation: { language } });
assert.equal(record?.version, 1);
assert.equal(resolveSettings(record).dictation.language, language);
}
});
it("rejects invalid API values and drops invalid stored preferences", () => {
assert.deepEqual(parseDictationSettingsBody({}), { ok: true, settings: {} });
assert.deepEqual(parseDictationSettingsBody({ language: "multi" }), { ok: true, settings: { language: "multi" } });
for (const value of [null, [], "pl", { language: "xx" }, { language: null }, { language: 3 }]) {
assert.equal(parseDictationSettingsBody(value).ok, false);
}
for (const dictation of [null, [], "pl", { language: "xx" }, { language: 3 }]) {
const record = sanitizeUserSettings({ version: 1, dictation, general: { autoUpdate: false } });
assert.equal(resolveSettings(record).dictation.language, "en");
assert.equal(record?.general?.autoUpdate, false);
}
});
});
const testData = mkdtempSync(path.join(tmpdir(), "lh-dictation-settings-"));
process.env.LH_DATA_DIR = testData;
after(() => { rmSync(testData, { recursive: true, force: true }); });
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
it("the section route persists language independently and rejects unsupported values without writing", async () => {
const { PUT } = await import("../app/api/settings/[section]/route.ts");
writeSettings({ version: 1, general: { autoUpdate: false } });
const put = (language: string) => PUT(new Request("http://localhost/api/settings/dictation", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language }),
}), { params: Promise.resolve({ section: "dictation" }) });
assert.equal((await put("pl")).status, 200);
const saved = { version: 1, dictation: { language: "pl" }, general: { autoUpdate: false } };
assert.deepEqual(readSettings(), saved);
assert.equal((await put("unsupported")).status, 400);
assert.deepEqual(readSettings(), saved);
const general = await PUT(new Request("http://localhost/api/settings/general", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoUpdate: true }),
}), { params: Promise.resolve({ section: "general" }) });
assert.equal(general.status, 200);
assert.deepEqual(readSettings(), { ...saved, general: { autoUpdate: true } }, "auto-update cannot erase dictation language");
});
+300
View File
@@ -0,0 +1,300 @@
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 { notifySettingsSaved } from "../components/settings-signal.ts";
import { AUTH_KEYS, DICTATION_LANGUAGES, type AuthKeyStatus, type ModelCatalogEntry, type UserSettings } from "../src/core/index.ts";
import { ModelMenuList } from "../components/settings/model-menu.tsx";
let dispose: (() => Promise<void>) | undefined;
let restore: (() => void) | undefined;
const byTest = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
const trigger = () => byTest("dictation-language");
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Search dictation languages"]')!;
const menu = () => document.querySelector('[data-testid="dictation-language-menu"]');
const click = async (element: HTMLElement) => { assert(element); await act(async () => { element.click(); }); };
async function key(element: HTMLElement, 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 = input()) {
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, dictation: { language: "en" } } 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,
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/dictation");
const body = JSON.parse(init!.body as string);
state.writes.push(body.language);
if (state.nextWrite) { const next = state.nextWrite; state.nextWrite = undefined; return next; }
if (state.failWrite) return Response.json({}, { status: 500 });
state.record = { ...state.record, dictation: body };
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];
}
};
// Register the outer dialog listener first: one Escape must close only
// the language menu, even when the settings dialog owns another listener.
doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; });
const { createRoot } = await import("react-dom/client");
const { DictationLanguageSetting } = await import("../components/dictation-language.tsx");
let root = createRoot(doc.getElementById("root")!);
const mount = async () => { await act(async () => { root.render(<DictationLanguageSetting />); }); };
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
await mount();
// Fixtures represent distinct server records; remounts within a fixture
// deliberately keep the window-lifetime confirmed preference.
await act(async () => { notifySettingsSaved("dictation"); });
return {
state,
render: async (element: React.ReactNode) => { await act(async () => { root.render(element); }); },
remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); },
};
}
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
test("an initial load failure is visible and opening the menu again can retry", async () => {
const f = await fixture(true);
await click(trigger());
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not load/);
f.state.failRead = false;
await click(trigger());
await click(trigger());
assert.equal(menu()!.querySelector('[role="alert"]'), null);
await search("Polish");
await key(input(), "Enter");
assert.match(trigger().textContent!, /Polish/);
});
test("the real picker searches by language, chooses with Arrow/Enter, and restores focus", async () => {
const f = await fixture();
assert.match(trigger().textContent!, /English/);
for (const token of ["h-7", "rounded-sm", "border-border", "bg-background", "font-ui", "text-xs"]) {
assert.ok(trigger().className.split(" ").includes(token), `shared settings trigger: ${token}`);
}
await click(trigger());
assert.match(input().className, /lh-input/);
assert.equal(document.activeElement, input());
assert.equal(document.querySelector('[data-testid="dictation-language-list"] button')!.textContent, "Multilingual");
await search("Polish");
assert.equal(document.querySelectorAll('[data-testid="dictation-language-list"] button').length, 1);
await key(input(), "ArrowDown");
await key(input(), "Enter");
assert.deepEqual(f.state.writes, ["pl"]);
assert.match(trigger().textContent!, /Polish/);
assert.equal(menu(), null);
assert.equal(document.activeElement, trigger());
await f.remount();
assert.match(trigger().textContent!, /Polish/);
});
test("Enter on a focused option preserves native button activation", async () => {
const f = await fixture();
await click(trigger());
const option = byTest("dictation-language-multi");
option.focus();
let prevented = false;
await act(async () => {
const event = new window.Event("keydown", { bubbles: true, cancelable: true });
Object.assign(event, { key: "Enter" });
option.dispatchEvent(event);
prevented = event.defaultPrevented;
// linkedom does not implement native keyboard activation. Follow the
// browser's default action only if the handler left it uncancelled.
if (!prevented) option.click();
});
assert.equal(prevented, false, "focused option Enter must retain its native click");
assert.deepEqual(f.state.writes, ["multi"]);
assert.equal(menu(), null);
assert.match(trigger().textContent!, /Multilingual/);
});
test("empty results cannot save; Escape and outside click close the menu without closing settings", async () => {
const f = await fixture();
await click(trigger());
await search("zzzz-no-language");
assert.match(menu()!.textContent!, /No languages match/);
await key(input(), "Enter");
assert.deepEqual(f.state.writes, []);
await key(input(), "Escape");
assert.equal(menu(), null);
assert.equal(f.state.outerEscapes, 0);
await click(trigger());
await key(trigger(), "Escape");
assert.equal(menu(), null);
assert.equal(f.state.outerEscapes, 0);
await click(trigger());
await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); });
assert.equal(menu(), null);
});
test("failed saves retain the confirmed choice and the same menu can retry", async () => {
const f = await fixture();
f.state.failWrite = true;
await click(trigger());
await search("Polish");
await key(input(), "Enter");
assert.match(trigger().textContent!, /English/);
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not save/);
f.state.failWrite = false;
await click(byTest("dictation-language-pl"));
assert.equal(menu(), null);
assert.match(trigger().textContent!, /Polish/);
assert.deepEqual(f.state.writes, ["pl", "pl"]);
});
test("a remounted picker stays disabled throughout an outstanding save", async () => {
const f = await fixture();
let complete!: (response: Response) => void;
f.state.nextWrite = new Promise((resolve) => { complete = resolve; });
await click(trigger());
await click(byTest("dictation-language-multi"));
assert.equal(trigger().disabled, true);
await f.remount();
assert.equal(trigger().disabled, true);
await click(trigger());
assert.equal(menu(), null);
f.state.record.dictation = { language: "multi" };
await act(async () => { complete(Response.json({ settings: f.state.record })); });
assert.equal(trigger().disabled, false);
assert.match(trigger().textContent!, /Multilingual/);
assert.deepEqual(f.state.writes, ["multi"]);
});
test("capture locks the row and dismisses an open language menu until capture ends", async () => {
await fixture();
await click(trigger());
await act(async () => { setDictationActive(true); });
assert.equal(trigger().disabled, true);
assert.match(byTest("row-dictation-language").className, /opacity-40/);
assert.equal(menu(), null);
await act(async () => { setDictationActive(false); });
assert.equal(trigger().disabled, false);
await click(trigger());
assert(menu());
});
for (const source of ["none", "file", "env"] as const) test(`Language is below the full key row 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.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram API key with the Member role or higher, then add it above.");
assert.match(byTest("dictation-setup-hint").className, /text-muted-foreground/);
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://console.deepgram.com/");
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(menu());
await key(input(), "Escape");
assert.equal(menu(), null);
assert.equal(f.state.outerEscapes, 0);
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"), "first Escape closes only the menu");
});
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/);
});
test("clearing each search restores the complete catalog order with Multilingual first", async () => {
await fixture();
await click(trigger());
const order = () => [...document.querySelectorAll('[data-testid="dictation-language-list"] button')].map((item) => item.getAttribute("data-testid")!.replace("dictation-language-", ""));
const expected = DICTATION_LANGUAGES.map((item) => item.code);
assert.deepEqual(order(), expected);
for (const query of ["en", "multi", "Polish", "zzzzz"]) {
await search(query);
await search("");
assert.deepEqual(order(), expected, `catalog order after clearing ${query}`);
}
});
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDictationLanguageStore } from "../components/dictation-preference.ts";
test("all readers immediately receive the confirmed write without another GET", async () => {
let reads = 0;
let notifications = 0;
let failReads = false;
const store = createDictationLanguageStore(async (_url, init) => {
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: JSON.parse(init.body as string) } });
reads++;
return failReads ? Response.json({}, { status: 500 }) : Response.json({ settings: null });
}, () => { notifications++; });
assert.equal(await store.load(), "en");
const observed: string[] = [];
const off = store.subscribe(() => { observed.push(`${store.getSnapshot().language}:${store.getSnapshot().saving}`); });
failReads = true;
assert.equal(await store.save("multi"), true);
assert.equal(await store.getLanguage(), "multi");
assert.equal(reads, 1);
assert.equal(notifications, 1);
assert(observed.includes("multi:true"));
assert.equal(observed.at(-1), "multi:false");
off();
});
test("ordinary loads and repeated recordings reuse the initial confirmed value", async () => {
let reads = 0;
const store = createDictationLanguageStore(async () => {
reads++;
if (reads > 1) throw new Error("should not reread");
return Response.json({ settings: { version: 1, dictation: { language: "fr" } } });
});
assert.deepEqual(await Promise.all([store.getLanguage(), store.load()]), ["fr", "fr"]);
for (let i = 0; i < 3; i++) {
const unsubscribe = store.subscribe(() => {});
assert.equal(await store.getLanguage(), "fr");
assert.equal(await store.load(), "fr");
unsubscribe();
}
assert.equal(reads, 1);
});
test("a failed external refresh invalidates the cache until a successful retry", async () => {
let fail = false;
const store = createDictationLanguageStore(async () => fail
? Response.json({}, { status: 500 })
: Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
await store.load();
fail = true;
await assert.rejects(store.load(true), /Could not load/);
await assert.rejects(store.getLanguage(), /Could not load/);
fail = false;
assert.equal(await store.getLanguage(), "pl");
assert.equal(store.getSnapshot().error, null);
});
test("stale reads cannot overwrite a newer write and subscribers see busy before transport runs", async () => {
let stale!: (response: Response) => void;
let nextRead: Promise<Response> | undefined;
const store = createDictationLanguageStore(async (_url, init) => {
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
if (nextRead) { const response = nextRead; nextRead = undefined; return response; }
return Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
});
await store.load();
nextRead = new Promise((resolve) => { stale = resolve; });
const oldRead = store.load(true);
await Promise.resolve();
let startedDuringPublish: Promise<string> | undefined;
const off = store.subscribe(() => {
if (store.getSnapshot().saving && !startedDuringPublish) startedDuringPublish = store.getLanguage();
});
assert.equal(await store.save("multi"), true);
stale(Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
assert.equal(await oldRead, "multi");
assert.equal(await startedDuringPublish, "multi");
assert.equal(store.getSnapshot().language, "multi");
off();
});
for (const newestFirst of [false, true]) test(`overlapping reads keep the newest value, newest resolves first: ${newestFirst}`, async () => {
const responses: ((response: Response) => void)[] = [];
const store = createDictationLanguageStore(() => new Promise((resolve) => { responses.push(resolve); }));
const first = store.load();
await Promise.resolve();
const second = store.load(true);
await Promise.resolve();
const old = Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
const fresh = Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
if (newestFirst) { responses[1]!(fresh); responses[0]!(old); }
else { responses[0]!(old); responses[1]!(fresh); }
assert.deepEqual(await Promise.all([first, second]), ["multi", "multi"]);
assert.equal(await store.getLanguage(), "multi");
});
test("capture also waits for a write initiated as a pending read publishes its result", async () => {
const store = createDictationLanguageStore(async (_url, init) => Response.json({ settings: {
version: 1, dictation: { language: init?.method === "PUT" ? "multi" : "en" },
} }));
let started = false;
const off = store.subscribe(() => {
if (!started && store.getSnapshot().language === "en") {
started = true;
void store.save("multi");
}
});
assert.equal(await store.getLanguage(), "multi");
off();
});
+259
View File
@@ -0,0 +1,259 @@
import React, { act, useLayoutEffect, useRef, useState } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { QuestionDock } from "../components/question-dock.tsx";
import { PracticeDock } from "../components/practice-dock.tsx";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
const observers: TestObserver[] = [];
class TestObserver {
elements = new Set<Element>();
disconnected = false;
constructor(readonly callback: () => void) { observers.push(this); }
observe(el: Element) { this.elements.add(el); }
disconnect() { this.disconnected = true; this.elements.clear(); }
}
Object.assign(globalThis, {
ResizeObserver: TestObserver,
getComputedStyle: (el: HTMLElement) => ({
borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px",
borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px",
paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px",
paddingBottom: "0px",
overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"),
}),
});
// Import after installing window so the hook uses its browser layout effect.
const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts");
const normal = {
columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0,
shellBorderHeight: 2, contentHeight: 800, questionHeight: 600,
questionPaddingHeight: 12,
};
describe("dockHeightBudget — whole dock, not just its question", () => {
it("subtracts answer controls, toolbar, borders and breathing gap", () => {
assert.deepEqual(dockHeightBudget(normal), {
viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false,
});
});
it("accounts for growing answer fields and the separate reading draft", () => {
const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 });
assert.equal(result.questionMaxHeight, 156);
assert.equal(result.shellMaxHeight, 438);
});
it("preserves a computed zero and deliberately selects whole-content scrolling", () => {
const result = dockHeightBudget({ ...normal, columnHeight: 292 });
assert.equal(result.questionMaxHeight, 0);
assert.equal(result.scrollWholeContent, true);
});
it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => {
assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true);
assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false);
});
it("never creates negative caps, even if the toolbar exceeds the column", () => {
const result = dockHeightBudget({ ...normal, columnHeight: 20 });
assert.equal(result.viewportMaxHeight, 0);
assert.equal(result.shellMaxHeight, 0);
assert.equal(result.questionMaxHeight, 0);
assert.equal(result.scrollWholeContent, true);
});
it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => {
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false);
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true);
});
});
// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout.
// Live-browser checks separately exercise actual field sizing and WAAPI.
const sizes: Record<string, number> = {};
let animating = false;
dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : [];
const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 });
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
dom.window.HTMLElement.prototype.getBoundingClientRect = function () {
const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part;
if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} };
return originalRect.call(this);
};
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } });
Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } });
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } });
const ignoreHeight = () => {};
function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: {
expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void;
onHeight?: (height: number) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const blockRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [inset, setInset] = useState(0);
useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight });
// Like DockOverlay's later morph/reserve layout effect: constraints must
// already be applied in this same commit, not an after-paint correction.
useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); });
return <><div ref={scrollRef} data-part="column" /><div ref={blockRef} data-part="block" data-inset={inset}>
<div ref={viewportRef} data-part="viewport"><div ref={bodyRef} data-part="body">
<div ref={shellRef} data-part="shell"><div ref={contentRef} data-part="content" key={practice ? "practice" : "question"}>
{practice ? <PracticeDock problemCallId="p1" input={{ statement: "Long practice problem" }} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
: <QuestionDock toolName="probe" toolCallId="q1" input={{ question: "Long teaching question", options: [{ label: "A" }, { label: "B" }], correctAnswer: "A", explanation: "A is correct." }} expanded={expanded} dictationClaim={false} onExpand={() => {}} onSubmit={() => {}} />}
</div></div>
</div></div><div data-testid="toolbar" />
</div></>;
}
async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps<typeof Fixture>) => Promise<void>) => Promise<void>) {
resetSizes();
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const render = async (props = {}) => { await act(async () => root.render(<Fixture {...props} />)); };
try { await run(host, render); }
finally { await act(async () => root.unmount()); host.remove(); }
}
const part = (host: HTMLElement, name: string) => host.querySelector<HTMLElement>(`[data-part="${name}"]`)!;
describe("measured question/practice DOM and observer lifecycle", () => {
it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => {
await withFixture(async (host, render) => {
await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
assert.equal(part(host, "block").dataset.inset, "10");
assert.equal(part(host, "viewport").style.overflowY, "visible");
assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]')));
});
});
it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => {
await withFixture(async (host, render) => {
await render();
Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 });
await act(async () => observers.at(-1)!.callback());
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
});
});
it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => {
await withFixture(async (host, render) => {
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 });
await render({ expanded: false });
assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted");
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
assert.equal(part(host, "viewport").style.overflowY, "visible");
resetSizes();
await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
});
});
it("reconnects to practice's newly mounted question after collapsing and revealing", async () => {
await withFixture(async (host, render) => {
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 });
await render({ practice: true, expanded: false });
const collapsedObserver = observers.at(-1)!;
assert.equal(host.querySelector('[data-dock-scroll-area]'), null);
resetSizes();
await render({ practice: true, expanded: true });
assert.equal(collapsedObserver.disconnected, true);
assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!));
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
});
});
it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => {
await withFixture(async (host, render) => {
sizes.column = 292;
await render();
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none");
assert.equal(part(host, "viewport").style.overflowY, "auto");
assert.equal(part(host, "viewport").style.maxHeight, "200px");
assert.equal(part(host, "shell").style.maxHeight, "none");
sizes.column = 700;
await act(async () => observers.at(-1)!.callback());
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
assert.equal(part(host, "viewport").style.overflowY, "visible");
});
});
it("removes constraints in composer mode and disconnects observers on unmount", async () => {
let last: TestObserver;
await withFixture(async (host, render) => {
await render();
await render({ enabled: false });
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
assert.equal(part(host, "shell").style.maxHeight || "", "");
last = observers.at(-1)!;
});
assert.equal(last!.disconnected, true);
});
it("reports child-only resize heights to the reserve, but never mid-morph values", async () => {
await withFixture(async (_host, render) => {
const heights: number[] = [];
await render({ onHeight: h => heights.push(h) });
assert.equal(heights.at(-1), 862);
animating = true;
try {
sizes.block = 900;
await act(async () => observers.at(-1)!.callback());
assert.equal(heights.at(-1), 862, "animated block height was not reserved");
} finally { animating = false; }
await act(async () => observers.at(-1)!.callback());
assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render");
});
});
it("reconnects to a new keyed mode even when expansion is unchanged", async () => {
await withFixture(async (host, render) => {
await render();
const previous = observers.at(-1)!;
const oldContent = part(host, "content");
await render({ practice: true });
assert.equal(previous.disconnected, true);
assert.notEqual(part(host, "content"), oldContent);
assert.ok(observers.at(-1)!.elements.has(part(host, "content")));
});
});
it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => {
await withFixture(async (host, render) => {
await render();
const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"),
body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") };
assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true));
});
});
});
describe("wheel routing — question, short-window content, then transcript", () => {
it("keeps native wheel input in the question and then the whole-content viewport", () => {
const root = document.createElement("div");
root.innerHTML = '<div data-viewport style="overflow-y:auto"><div data-question style="overflow-y:auto"><span>text</span></div></div>';
const viewport = root.firstElementChild as HTMLElement;
const question = viewport.firstElementChild as HTMLElement;
const transcript = document.createElement("div"); transcript.scrollTop = 100;
for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; }
const wheel = (deltaY: number, deltaMode = 0) => {
let prevented = false;
forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript);
return prevented;
};
assert.equal(wheel(-20), false, "question can scroll up");
question.scrollTop = 0;
assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge");
viewport.scrollTop = 0;
assert.equal(wheel(-20), true);
assert.equal(transcript.scrollTop, 80);
question.scrollTop = viewport.scrollTop = 300;
assert.equal(wheel(1, 1), true);
assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized");
viewport.style.overflowY = "visible"; viewport.scrollTop = 50;
assert.equal(wheel(-20), false, "question still consumes upward movement");
question.style.overflowY = "hidden";
assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers");
assert.equal(wheel(0), false);
});
});
@@ -0,0 +1,90 @@
[
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo321f7_4202fae0",
"source": "messages.1.content.1.arguments.explanation",
"model": "google/gemini-3.7-flash",
"kind": "percent-at-display-end",
"raw": "To find the posterior probability $P(D \\\\mid +)$, we apply Bayes' theorem:\n$$\nP(D \\\\mid +) = \\\\frac{P(D \\\\cap +)}{P(+)} = \\\\frac{P(D)P(+ \\\\mid D)}{P(D)P(+ \\\\mid D) + P(\\\\neg D)P(+ \\\\mid \\\\neg D)}\n$$\n\nGiven:\n- $P(D) = 0.01$ (and thus $P(\\\\neg D) = 0.99$)\n- $P(+ \\\\mid D) = 0.90$\n- $P(+ \\\\mid \\\\neg D) = 0.05$\n\nEvaluating the numerator and denominator:\n$$\nP(D \\\\cap +) = 0.01 \\\\times 0.90 = 0.009\n$$\n$$\nP(\\\\neg D \\\\cap +) = 0.99 \\\\times 0.05 = 0.0495\n$$\n$$\nP(+) = 0.009 + 0.0495 = 0.0585\n$$\n\nTherefore:\n$$\nP(D \\\\mid +) = \\\\frac{0.009}{0.0585} = \\\\frac{9}{58.5} = \\\\frac{2}{13} \\\\approx 15.4\\\\%\n$$"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo321f7_4202fae0",
"source": "messages.5.content.1.arguments.explanation",
"model": "google/gemini-3.7-flash",
"kind": "percent-at-display-end",
"raw": "Because the two tests are conditionally independent given disease status:\n$$\nP(T_1 = +, T_2 = + \\\\mid D) = P(T_1 = + \\\\mid D) \\\\cdot P(T_2 = + \\\\mid D) = 0.90 \\\\times 0.90 = 0.81\n$$\n$$\nP(T_1 = +, T_2 = + \\\\mid \\\\neg D) = P(T_1 = + \\\\mid \\\\neg D) \\\\cdot P(T_2 = + \\\\mid \\\\neg D) = 0.10 \\\\times 0.10 = 0.01\n$$\n\nNow calculate the joint probabilities:\n$$\nP(D \\\\cap T_1 = + \\\\cap T_2 = +) = P(D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid D) = 0.01 \\\\times 0.81 = 0.0081\n$$\n$$\nP(\\\\neg D \\\\cap T_1 = + \\\\cap T_2 = +) = P(\\\\neg D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid \\\\neg D) = 0.99 \\\\times 0.01 = 0.0099\n$$\n\nTotal probability of two positive tests:\n$$\nP(T_1 = +, T_2 = +) = 0.0081 + 0.0099 = 0.0180\n$$\n\nApplying Bayes' theorem:\n$$\nP(D \\\\mid T_1 = +, T_2 = +) = \\\\frac{0.0081}{0.0180} = \\\\frac{81}{180} = \\\\frac{9}{20} = 45\\\\%\n$$"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo4op90_74572548",
"source": "messages.1.content.2.arguments.explanation",
"language": "pt",
"model": "deepseek/deepseek-v4.1-flash",
"kind": "percent-at-display-end",
"raw": "Em $10\\\\,000$ pessoas: $100$ estão doentes e $99$ dessas testam positivo; $9\\\\,900$ estão saudáveis e $5\\\\%$ delas, isto é $495$, testam positivo. Total de positivos: $99 + 495 = 594$. Entre os positivos, a fração de doentes é\\n$$\\n\\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%\\n$$\\nA base pequena de doentes faz os falsos positivos ($495$) dominarem os verdadeiros positivos ($99$)."
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo4op90_74572548",
"source": "messages.3.content.2.arguments.explanation",
"language": "pt",
"model": "deepseek/deepseek-v4.1-flash",
"kind": "percent-at-display-end",
"raw": "Continua valendo a mesma estrutura de Bayes, agora com números diferentes. Em $10\\\\,000$ pessoas: $200$ doentes, das quais $90\\\\%$ testam positivo $= 180$; $9\\\\,800$ saudáveis, das quais $5\\\\%$ testam positivo $= 490$ falsos positivos. Total de positivos: $180 + 490 = 670$.\\n$$\\nP(\\\\text{doente} \\\\mid +) = \\\\frac{180}{670} \\\\approx 26{,}9\\\\%\\n$$\\nNote que a especificidade $95\\\\%$ entra na conta pela sua taxa de falso positivo $1 - 0{,}95 = 0{,}05$, pesada por $P(\\\\text{saudável}) = 0{,}98$."
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo8kgtc_65767ffd",
"source": "messages.5.content.1.arguments.question",
"language": "es",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "document-math-framing",
"raw": "Una enfermedad afecta al $1\\%$ de una población. La prueba tiene una sensibilidad del $99\\%$ (probabilidad de dar positivo si la enfermedad está presente) y una tasa de falsos positivos del $5\\%$ (probabilidad de dar positivo si no está presente). Una persona al azar da positivo. ¿Cuál es la probabilidad de que realmente tenga la enfermedad?\\n\\n$$\\nP(\\text{enf} \\mid +) \\;=\\; ?$$"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo91klg_5332fa79",
"source": "messages.3.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "条件概率把样本空间缩小到条件所指定的那一部分。已知\"检测阳性\",人群就从 $1000$ 人缩到 $140$ 人,其中患病的有 $45$ 人,所以\\n$$\\nP(\\\\text{患病}\\\\mid\\\\text{阳性})=\\\\frac{P(\\\\text{患病}\\\\cap\\\\text{阳性})}{P(\\\\text{阳性})}=\\\\frac{45/1000}{140/1000}=\\\\frac{45}{140}\\\\approx 32.1\\\\%.\\n$$\\n另外三个数各有各的含义:$\\\\frac{45}{50}=90\\\\%$ 是 $P(\\\\text{阳性}\\\\mid\\\\text{患病})$(敏感度,条件方向反了);$\\\\frac{45}{1000}=4.5\\\\%$ 是联合概率 $P(\\\\text{患病}\\\\cap\\\\text{阳性})$(没做条件化);$\\\\frac{140}{1000}=14\\\\%$ 是 $P(\\\\text{阳性})$(不分是否患病的总阳性率)。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo91klg_5332fa79",
"source": "messages.7.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "用 $10000$ 人(或直接用全概率公式)算分母:\\n$$\\n\\\\begin{aligned}\\n\\\\text{患病且阳性}&=10000\\\\times 0.01\\\\times 0.99=99,\\\\\\\\\\n\\\\text{未患病但阳性}&=10000\\\\times 0.99\\\\times 0.05=495.\\n\\\\end{aligned}\\n$$\\n阳性总数 $=99+495=594$,所以\\n$$\\nP(D\\\\mid +)=\\\\frac{99}{594}\\\\approx 16.7\\\\%.\\n$$\\n各错误选项对应的具体失误:$99\\\\%$ 是把敏感度 $P(+\\\\mid D)$ 当成了 $P(D\\\\mid +)$(条件方向颠倒);$95.2\\\\%=\\\\frac{0.99}{0.99+0.05}$ 是把阳性率和假阳性率直接当人数用,漏掉了基础发生率的加权($99\\\\%$ 的人未患病,所以少量假阳性也架不住人多);$1\\\\%$ 是看到了阳性结果却不更新概率。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo91klg_5332fa79",
"source": "messages.9.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "两次阳性后的后验,用同样的公式但似然要平方:\\n$$\\n\\\\begin{aligned}\\nP(++\\\\mid D)&=0.99^2=0.9801,\\\\\\\\\\nP(++\\\\mid D')&=0.05^2=0.0025.\\n\\\\end{aligned}\\n$$\\n$$\\nP(D\\\\mid ++)\\\\frac{0.99^2\\\\times 0.01}{0.99^2\\\\times 0.01+0.05^2\\\\times 0.99}=\\\\frac{0.009801}{0.009801+0.002475}\\\\approx 79.8\\\\%.\\n$$\\n关键在于**两条分支都要平方**:患病分支的似然变成 $0.99^2$,未患病分支的假阳性率变成 $0.05^2$($5\\%$ 是很小的数,平方后变得更小,所以假阳性这一侧收缩得比患病侧更狠,后验从 $16.7\\\\%$ 跳到约 $80\\\\%$)。其他选项的失误:$98\\\\%$ 是把 $P(++\\\\mid D)$ 当成了 $P(D\\\\mid ++)$;$16.5\\\\%$ 只对患病分支用了两次独立,未患病分支仍用单次假阳性率;$33\\\\%$ 是把单次后验直接乘 $2$——两次阳性提供的证据不是相加的,而要在似然层面相乘。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo9c830_3866e4a6",
"source": "messages.7.content.1.arguments.explanation",
"language": "zh-Hans",
"model": "anthropic/claude-haiku-5.5",
"kind": "percent-at-display-end",
"raw": "阳性总人数:真阳性 $100\\\\times0.9=90$,假阳性 $9900\\\\times0.05=495$,合计 $585$。\n\n所求的是阳性者中患病者的占比,分母是全部阳性者:\n$$\n\\\\frac{90}{585}\\\\approx15.4\\\\%\n$$\n\n$90\\\\%$ 是患病者中阳性的比例,分母错成了患病者;$84.6\\\\%$ 是阳性者中健康者的占比,回答的是另一个问题;$5\\\\%$ 是健康者中的假阳性率,分母是健康人群。"
},
{
"group": "multilingual-20261025-01",
"chatId": "chat_mvo9nu8w_dcc7add9",
"source": "messages.3.content.1.arguments.explanation",
"language": "id",
"model": "xiaomi/mimo-v2.6-flash",
"kind": "percent-at-display-end",
"raw": "Pakai aturan Bayes dengan menghitung dulu kejadian gabungan dalam satu kelompok besar, misalnya $10.000$ orang:\\n\\n- Sakit dan positif: $10.000 \\\\times 0{,}01 \\\\times 0{,}99 = 99$ orang.\\n- Sehat dan positif: $10.000 \\\\times 0{,}99 \\\\times 0{,}05 = 495$ orang.\\n\\nSemua yang hasilnya positif: $99 + 495 = 594$ orang. Maka\\n$$\\nP(\\\\text{sakit}\\\\mid +) = \\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%.\\n$$\\n\\nYang menipu di sini adalah base rate: kelompok sehat jauh lebih besar, sehingga meski hanya 5% dari mereka yang salah positif, jumlahnya tetap 5 kali lipat jumlah positif yang benar. Nilai $95{,}2\\\\%$ muncul kalau kelompok sakit dan sehat dianggap sama besar ($\\\\frac{99}{99+5}$); nilai $99\\\\%$ muncul kalau arah kondisinya dibalik, membaca $P(+\\\\mid \\\\text{sakit})$ sebagai $P(\\\\text{sakit}\\\\mid +)$."
}
]
+135
View File
@@ -0,0 +1,135 @@
{
"notice": "Unmodified model-authored fields from the frozen multilingual corpus. oldDisplay records the diagnosed application damage, not an expected output.",
"cases": [
{
"run": "pilot-20261025-01",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 1,
"chatId": "chat_mvo1rzjs_056eac45",
"source": "messages.5.content.1.arguments.explanation",
"raw": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result.",
"oldDisplay": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result."
},
{
"run": "pilot-20261025-01",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 1,
"chatId": "chat_mvo1sdnj_8f2d8c5e",
"source": "messages.9.content.1.arguments.explanation",
"raw": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.",
"oldDisplay": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$."
},
{
"run": "pilot-20261025-01",
"model": "anthropic/claude-haiku-5.5",
"language": "en",
"replicate": 1,
"chatId": "chat_mvo30cfe_57114723",
"source": "messages.3.content.1.arguments.explanation",
"raw": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term.",
"oldDisplay": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term."
},
{
"run": "multilingual-20261025-01-zh-Hans-r1",
"model": "openai/gpt-6-luna",
"language": "zh-Hans",
"replicate": 1,
"chatId": "chat_mvo60bsn_1cbbe884",
"source": "messages.3.content.1.arguments.question",
"raw": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?",
"oldDisplay": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?"
},
{
"run": "multilingual-20261025-01-zh-Hans-r1",
"model": "xiaomi/mimo-v2.6-flash",
"language": "zh-Hans",
"replicate": 1,
"chatId": "chat_mvo66u6w_a233fc16",
"source": "messages.9.content.2.arguments.explanation",
"raw": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。",
"oldDisplay": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。"
},
{
"run": "multilingual-20261025-01-id-r1",
"model": "openai/gpt-6-luna",
"language": "id",
"replicate": 1,
"chatId": "chat_mvo6q2lq_6af14437",
"source": "messages.7.content.1.arguments.explanation",
"raw": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$.",
"oldDisplay": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$."
},
{
"run": "multilingual-20261025-01-id-r1",
"model": "z-ai/glm-5.3-flash",
"language": "id",
"replicate": 1,
"chatId": "chat_mvo77f3x_135f8d66",
"source": "messages.7.content.2.arguments.explanation",
"raw": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$.",
"oldDisplay": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$."
},
{
"run": "multilingual-20261025-01-en-r2",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 2,
"chatId": "chat_mvo7ei0l_9263330e",
"source": "messages.3.content.1.arguments.question",
"raw": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?",
"oldDisplay": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?"
},
{
"run": "multilingual-20261025-01-en-r2",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 2,
"chatId": "chat_mvo7exl3_e1fe93cd",
"source": "messages.1.content.1.arguments.explanation",
"raw": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid.",
"oldDisplay": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid."
},
{
"run": "multilingual-20261025-01-en-r2",
"model": "openai/gpt-6-luna",
"language": "en",
"replicate": 2,
"chatId": "chat_mvo7exl3_e1fe93cd",
"source": "messages.3.content.1.arguments.explanation",
"raw": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.",
"oldDisplay": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$."
},
{
"run": "multilingual-20261025-01-pt-r2",
"model": "openai/gpt-6-luna",
"language": "pt",
"replicate": 2,
"chatId": "chat_mvo7wn6c_d63ef3f1",
"source": "messages.7.content.1.arguments.explanation",
"raw": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível.",
"oldDisplay": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível."
},
{
"run": "multilingual-20261025-01-es-r2",
"model": "openai/gpt-6-luna",
"language": "es",
"replicate": 2,
"chatId": "chat_mvo8j89a_f6fc1cc0",
"source": "messages.3.content.1.arguments.explanation",
"raw": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$.",
"oldDisplay": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$."
},
{
"run": "multilingual-20261025-01-id-r2",
"model": "openai/gpt-6-luna",
"language": "id",
"replicate": 2,
"chatId": "chat_mvo9mesu_dbb56f1f",
"source": "messages.9.content.1.arguments.explanation",
"raw": "Untuk $\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$.",
"oldDisplay": "Untuk $\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$."
}
]
}
@@ -0,0 +1,19 @@
{
"provenance": "multilingual-20261025-01; original collection findings, not the failed r1 rendering",
"cases": [
{
"chatId": "chat_mvo6kxzl_540b570e",
"source": "messages.1.content.2.arguments.explanation",
"model": "z-ai/glm-5.3-flash",
"raw": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\\\cdot3+2\\\\cdot0 & 1\\\\cdot1+2\\\\cdot2\\\\\\\\ 0\\\\cdot3+1\\\\cdot0 & 0\\\\cdot1+1\\\\cdot2\\\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。",
"oldDisplay": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\cdot3+2\\cdot0 & 1\\cdot1+2\\cdot2\\\\\\\\ 0\\cdot3+1\\cdot0 & 0\\cdot1+1\\cdot2\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。"
},
{
"chatId": "chat_mvo9oquq_74bfd7f6",
"source": "messages.3.content.1.arguments.explanation",
"model": "xiaomi/mimo-v2.6-flash",
"raw": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\\\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\\\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\\\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\\\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\\\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif.",
"oldDisplay": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif."
}
]
}
+5 -1
View File
@@ -657,6 +657,7 @@ describe("graph ledger store", () => {
import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts";
import { deleteChatSession, sessionCoverage } from "../src/server/chats.ts";
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { jobStore } from "../src/store/jobs.ts";
import {
@@ -811,11 +812,13 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
const turnedAway = await deleteChatSession(dA.id, "delete-nodes");
assert.deepEqual(turnedAway, { ok: false, status: 409, code: "turn_in_flight", error: "A turn is running in this session — try again when it finishes", jobIds: [] });
endTurn(dA.id);
// 409 — running background jobs.
// 409 — running background jobs; a refused delete keeps its notice.
rememberTurnFailure(dA.id, "The reply could not be saved.");
const job = jobStore.createJob({ kind: "research", chatId: dA.id, toolCallId: "del-call-1" });
const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes");
assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409");
if (jobsBlocked.code === "jobs_running") assert.deepEqual(jobsBlocked.jobIds, [job.id]);
assert.match(await settledTurnResponse(dA.id).text(), /The reply could not be saved\./);
assert.equal(jobStore.deleteJobsForChat(dA.id), 1, "test cleanup — the job file is gone");
// Success — a terminal job rides along; the chat record dies last.
@@ -830,6 +833,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.ok(outcome.graphRevision > 0, "the version stamp moved");
}
assert.equal(chatStore.getChat(dA.id), null, "the chat record is gone");
assert.doesNotMatch(await settledTurnResponse(dA.id).text(), /"type":"error"/, "deletion clears its transient notice");
assert.equal(jobStore.getJob(doneJob.id), null, "the chat's job records are gone");
// And a second call is an honest 404.
assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" });
+331
View File
@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { Agent } from "@earendil-works/pi-agent-core";
import { after, afterEach, test } from "node:test";
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { toLlmMessages, type LhMessage } from "../src/core/native-messages.ts";
import { buildSubagentSteer, type JobKind, type SteerMessage } from "../src/core/contracts.ts";
// Install isolation before importing any storage/configuration composition.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-job-delivery-"));
process.env.LH_DATA_DIR = path.join(directory, "data");
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const { stores } = await import("../src/server/stores.ts");
const { jobTools } = await import("../src/agent/tools/jobs.ts");
const { createJobRunner, researchModelOverride } = await import("../src/agent/subagents/runner.ts");
const { isJobLive } = await import("../src/agent/subagents/aborts.ts");
const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
const {
deliverJobResult, discardChatDeliveries, noteSteersDurable, recoverStaleJobs,
registerLiveAgent, unregisterLiveAgent, settleSteers, setTurnRunner,
} = await import("../src/server/jobs.ts");
const { activityFeed } = await import("../src/server/observability.ts");
const { deleteChatSession } = await import("../src/server/chats.ts");
const chats: string[] = [];
const result = { brief: "Fixture research result", sources: [] };
const question = {
id: "question-message", role: "assistant", content: [{ type: "toolCall", id: "question-call", name: "quiz", arguments: {} }],
api: "openai-completions", provider: "faux", model: "faux", timestamp: 1,
} as unknown as LhMessage;
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function until(predicate: () => boolean): Promise<void> {
const end = Date.now() + 8000;
while (!predicate()) { assert(Date.now() < end, "fixture condition timed out"); await delay(10); }
}
function sweep(): void {
const global = globalThis as { __lhWakePipeline?: { lastSweepAt: number } };
if (global.__lhWakePipeline) global.__lhWakePipeline.lastSweepAt = 0;
recoverStaleJobs();
}
function chat(blocked = false) {
const value = stores.chatStore.createChat("Isolated delivery fixture");
chats.push(value.id);
if (blocked) stores.chatStore.saveMessages(value.id, [question]);
return value;
}
function pending(chatId: string) {
const value = stores.jobStore.createJob({ kind: "research", chatId, toolCallId: "fixture-call" });
const job = stores.jobStore.updateJob(value.id, {
status: "done", result, activity: "finished", delivery: { kind: "result", status: "pending" },
})!;
return { job, steer: buildSubagentSteer(job, result) };
}
function agent() {
const handed: SteerMessage[] = [];
return { handed, value: { steer: (marker: SteerMessage) => handed.push(marker) } as unknown as Parameters<typeof registerLiveAgent>[1] };
}
function persist(chatId: string, steers: readonly LhMessage[]): void {
stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...steers]);
noteSteersDurable(chatId, steers);
}
afterEach(() => {
researchModelOverride.current = undefined;
for (const id of chats.splice(0)) {
unregisterLiveAgent(id); endTurn(id); discardChatDeliveries(id);
stores.jobStore.deleteJobsForChat(id); stores.chatStore.deleteChat(id);
}
setTurnRunner(async () => 500);
});
after(() => fs.rmSync(directory, { recursive: true, force: true }));
for (const [kind, input] of Object.entries({
research: { question: "Saved research question", context: "Saved context" },
demo: { title: "Fixture", brief: "Saved demo brief" },
visual: { brief: "Saved figure brief" },
pdf: { path: "fixture.pdf", question: "Saved PDF question" },
})) test(`${kind}: dispatch saves input before spawn and does not overwrite it on repeated dispatch`, async () => {
const owner = chat();
let spawns = 0;
const tools = jobTools({ chatId: owner.id, jobStore: stores.jobStore, spawn: (job) => {
spawns++;
assert.deepEqual(stores.jobStore.getJob(job.id)!.input, input);
} });
await tools[kind]!.execute!("fixture-dispatch", input);
await tools[kind]!.execute!("fixture-dispatch", input);
assert.equal(spawns, 1);
assert.equal(stores.jobStore.listJobs(owner.id)[0]!.kind, kind as JobKind);
});
test("saved question survives absent transcript; runner ends while delivery is still pending", async () => {
const owner = chat(true);
const input = { question: "The dispatch turn was never saved", context: "Fixture context" };
const job = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "lost-turn", input });
const faux = createFauxCore({});
let seen = "";
faux.setResponses([(context) => { seen = JSON.stringify(context.messages); return fauxAssistantMessage("Fixture brief"); }]);
researchModelOverride.current = { model: faux.getModel(), streamFn: faux.streamSimple };
let delivery: Promise<void> | undefined;
const runner = createJobRunner({ ...stores, deliver: (steer) => { delivery = deliverJobResult(steer); return delivery; } });
await runner.runJob(job.id);
assert(seen.includes(input.question)); assert(seen.includes(input.context));
assert.equal(isJobLive(job.id), false, "worker registration no longer spans delivery");
assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
assert(!activityFeed().jobs.some((item) => item.id === job.id), "no running background-agent row");
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
await delivery;
});
test("missing or blank research question records an honest failure without invoking a model", async () => {
for (const input of [undefined, { question: " " }]) {
const owner = chat();
const job = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "missing-question", input });
const faux = createFauxCore({}); let calls = 0;
faux.setResponses([() => { calls++; return fauxAssistantMessage("must not run"); }]);
researchModelOverride.current = { model: faux.getModel(), streamFn: faux.streamSimple };
const steers: SteerMessage[] = [];
await createJobRunner({ ...stores, deliver: async (steer) => { steers.push(steer); } }).runJob(job.id);
assert.equal(calls, 0);
assert.equal(stores.jobStore.getJob(job.id)!.error, "research question unavailable");
assert.equal(stores.jobStore.getJob(job.id)!.status, "error");
assert.match(steers[0]!.content, /^Research failed: the research question is unavailable\./);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
}
});
test("repeated recovery behind a question yields one handed and one durable marker", async () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
let wakes = 0; setTurnRunner(async () => { wakes++; return 500; });
sweep(); sweep(); sweep();
const first = deliverJobResult(steer);
assert.strictEqual(deliverJobResult(steer), first, "duplicate callers share one logical delivery");
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.deepEqual(next.handed.map((message) => message.id), [steer.id]);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
await first;
assert.equal(wakes, 0);
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered");
});
for (const refused of [409, 403]) test(`duplicate during a ${refused} wake retains one logical owner`, async () => {
const owner = chat(); const { steer } = pending(owner.id);
let release!: () => void; const gate = new Promise<void>((resolve) => { release = resolve; });
let attempts = 0;
setTurnRunner(async (id, input) => {
assert.equal(input.type, "steers"); if (input.type !== "steers") return 500;
assert.equal(input.steers.length, 1);
attempts++;
if (attempts === 1) { await gate; return refused; }
persist(id, input.steers); return 200;
});
const first = deliverJobResult(steer);
await until(() => attempts === 1);
assert.strictEqual(deliverJobResult(steer), first, "wake cargo is still in the registry");
release();
if (refused === 403) {
await delay(30);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
}
await first;
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
});
test("failed start stays quiet across sweeps, then joins a learner run", async () => {
const owner = chat(); const { job, steer } = pending(owner.id);
let attempts = 0; setTurnRunner(async () => { attempts++; return 503; });
const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30);
sweep(); sweep(); await delay(900);
assert.equal(attempts, 1);
assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
assert.equal(stores.jobStore.getJob(job.id)!.error, undefined);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id);
await delivery;
});
test("HTTP acceptance without a saved marker is not delivery completion or a retry trigger", async () => {
const owner = chat(); const { job, steer } = pending(owner.id);
let attempts = 0;
const accepted = agent();
setTurnRunner(async () => {
attempts++; assert(beginTurn(owner.id));
registerLiveAgent(owner.id, accepted.value, false, new Set([steer.id]));
return 200;
});
const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], true, accepted.value);
sweep(); await delay(900); assert.equal(attempts, 1);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 1);
persist(owner.id, next.handed); unregisterLiveAgent(owner.id); endTurn(owner.id); await delivery;
});
test("a racing foreground settlement cannot release an outstanding staged wake", async () => {
const owner = chat(); const { steer } = pending(owner.id);
let started = false;
let release!: () => void;
const startup = new Promise<void>((resolve) => { release = resolve; });
let modelCopies = 0;
let nativeCopies = 0;
setTurnRunner(async (id, input) => {
assert.equal(input.type, "steers"); if (input.type !== "steers") return 500;
started = true;
// Subscription wake startup can await catalog refresh before claiming
// the session. Its cargo is not yet a durable input prefix.
await startup;
const faux = createFauxCore({});
faux.setResponses([(context) => {
modelCopies = JSON.stringify(context.messages).split(result.brief).length - 1;
return fauxAssistantMessage("Fixture wake response");
}]);
const wake = new Agent({ initialState: { model: faux.getModel(), messages: input.steers }, convertToLlm: toLlmMessages, streamFn: faux.streamSimple });
assert(beginTurn(id));
registerLiveAgent(id, wake, false, new Set(input.steers.map((message) => message.id)));
await wake.continue();
nativeCopies = wake.state.messages.filter((message) => (message as LhMessage).id === steer.id).length;
persist(id, wake.state.messages.filter((message) => message.role !== "system") as LhMessage[]);
unregisterLiveAgent(id); endTurn(id); settleSteers(id, stores.chatStore.getChat(id)!.messages, false, wake);
return 200;
});
const delivery = deliverJobResult(steer);
await until(() => started);
const foreground = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, foreground.value);
assert.equal(foreground.handed.length, 0, "outstanding wake owns the marker");
unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], false, foreground.value);
release(); await delivery;
assert.equal(modelCopies, 1, "one result in the model's initial context");
assert.equal(nativeCopies, 1, "no self-steering of staged wake cargo");
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
});
test("canonical save wins even if delivery bookkeeping fails; no marker replay", async () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
const delivery = deliverJobResult(steer);
const update = stores.jobStore.updateJob;
stores.jobStore.updateJob = (id, patch) => {
if (id === job.id && patch.delivery?.status === "delivered") throw new Error("fixture acknowledgement write failure");
return update(id, patch);
};
try { persist(owner.id, [steer]); await delivery; }
finally { stores.jobStore.updateJob = update; }
let wakes = 0; setTurnRunner(async () => { wakes++; return 500; });
sweep();
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered");
assert.equal(wakes, 0);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.equal(next.handed.length, 0); unregisterLiveAgent(owner.id); endTurn(owner.id);
});
test("old running results become finished pending work; old terminal records are untouched", () => {
const owner = chat(true);
const old = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-pending" });
stores.jobStore.updateJob(old.id, { result });
const terminal = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-terminal" });
stores.jobStore.updateJob(terminal.id, { status: "done", result });
sweep();
assert.equal(stores.jobStore.getJob(old.id)!.status, "done");
assert.equal(stores.jobStore.getJob(old.id)!.delivery?.status, "pending");
assert.equal(stores.jobStore.getJob(terminal.id)!.delivery, undefined);
const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value);
assert.deepEqual(next.handed.map((message) => message.id), [`steer_${old.id}`]);
unregisterLiveAgent(owner.id); endTurn(owner.id);
});
test("fresh process restores a pending delivery on registration without a jobs poll", () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
const script = `
const { stores } = await import('./src/server/stores.ts');
const { registerLiveAgent, noteSteersDurable, unregisterLiveAgent } = await import('./src/server/jobs.ts');
const { beginTurn, endTurn } = await import('./src/server/turn-liveness.ts');
const handed = []; beginTurn(${JSON.stringify(owner.id)});
registerLiveAgent(${JSON.stringify(owner.id)}, { steer: marker => handed.push(marker) });
const record = stores.chatStore.getChat(${JSON.stringify(owner.id)});
stores.chatStore.saveMessages(record.id, [...record.messages, ...handed]);
noteSteersDurable(record.id, handed); unregisterLiveAgent(record.id); endTurn(record.id);
console.log(JSON.stringify(handed.map(marker => marker.id)));
`;
const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
cwd: process.cwd(), encoding: "utf8", timeout: 15000,
env: { PATH: process.env.PATH, HOME: path.join(directory, "home"), NODE_ENV: "test", LH_DATA_DIR: process.env.LH_DATA_DIR, LH_AUTH_FILE: process.env.LH_AUTH_FILE },
});
assert.equal(child.status, 0, child.stderr);
assert.deepEqual(JSON.parse(child.stdout.trim()), [steer.id]);
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered");
assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1);
});
test("deleting legacy completed work needs no preceding recovery poll", async () => {
const owner = chat(true);
const old = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-delete" });
stores.jobStore.updateJob(old.id, { result });
const outcome = await deleteChatSession(owner.id, "preserve-graph");
assert(outcome.ok);
assert.equal(stores.jobStore.getJob(old.id), null);
assert.equal(stores.chatStore.getChat(owner.id), null);
});
test("deletion still refuses a computing worker or a live turn", async () => {
const owner = chat();
const computing = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "computing-delete" });
const blocked = await deleteChatSession(owner.id, "preserve-graph");
assert(!blocked.ok && blocked.code === "jobs_running");
stores.jobStore.updateJob(computing.id, { status: "done" });
assert(beginTurn(owner.id));
const live = await deleteChatSession(owner.id, "preserve-graph");
assert(!live.ok && live.code === "turn_in_flight");
endTurn(owner.id);
});
test("deleting a session discards its pending deliveries without starting a turn", async () => {
const owner = chat(true); const { job, steer } = pending(owner.id);
let wakes = 0; setTurnRunner(async () => { wakes++; return 500; });
const delivery = deliverJobResult(steer);
const outcome = await deleteChatSession(owner.id, "preserve-graph");
assert(outcome.ok);
await delivery; await delay(450);
assert.equal(stores.jobStore.getJob(job.id), null);
assert.equal(stores.chatStore.getChat(owner.id), null);
assert.equal(wakes, 0);
});
+139 -51
View File
@@ -25,8 +25,9 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, afterEach, describe, it } from "node:test";
import { after, afterEach, before, describe, it } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
import {
accumulateJobUsage,
@@ -51,18 +52,20 @@ import { runningTurn } from "../src/server/turn-runs.ts";
import {
abortJob,
deliverJobResult,
noteSteerVisible,
noteSteersDurable,
discardChatDeliveries,
reconcileUserSteers,
recoverStaleJobs,
registerLiveAgent,
setJobRespawn,
setTurnRunner,
setTurnRunner as setRawTurnRunner,
type TurnRunner,
settleSteers,
steerLiveChat,
unregisterLiveAgent,
} from "../src/server/jobs.ts";
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { isJobLive, registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
import { persistTurn } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
@@ -140,11 +143,13 @@ afterEach(() => {
if (job && job.status === "running") {
jobStore.updateJob(id, { status: "done", activity: "settled (fence)" });
}
if (job?.delivery?.status === "pending") jobStore.updateJob(id, { delivery: { ...job.delivery, status: "delivered" } });
unregisterJobAbort(id);
} catch {
/* already gone */
}
}
for (const chatId of createdChats) discardChatDeliveries(chatId);
// Late wakes from a prior test must be inert: a no-op respawn can't
// increment anyone's counter, a no-op runner can't spawn real work.
setJobRespawn(() => {});
@@ -454,16 +459,25 @@ function fakeDeliver() {
steers,
deliver: async (steer: SteerMessage) => {
steers.push(steer);
const jobId = (steer.details as { jobId: string }).jobId;
const job = jobStore.getJob(jobId);
if (job && job.status === "running") {
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
jobStore.updateJob(jobId, { status: failed ? "error" : "done", activity: failed ? "failed" : "delivered" });
}
const job = jobStore.getJob((steer.details as { jobId: string }).jobId)!;
chatStore.saveMessages(job.chatId, [...chatStore.getChat(job.chatId)!.messages, steer]);
noteSteersDurable(job.chatId, [steer]);
},
};
}
/** Fake wakes model canonical input acceptance, not just HTTP acknowledgement. */
function setTurnRunner(run: TurnRunner): void {
setRawTurnRunner(async (chatId, input) => {
const status = await run(chatId, input);
if (status === 200 && input.type === "steers") {
const record = chatStore.getChat(chatId);
if (record) chatStore.saveMessages(chatId, [...record.messages, ...input.steers.filter((s) => !steerDelivered(record.messages, s.id))]);
}
return status;
});
}
// ─── 3. dispatch: the tool call acks mid-turn and spawns ─────────────────────
const RESEARCH_INPUT = {
@@ -526,7 +540,7 @@ describe("dispatch (async protocol §2)", () => {
const done = jobStore.getJob(job!.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered");
assert.equal(done.activity, "finished");
// The steer carries the brief on its model-facing content; the
// artifact payload rides details for the (UI) renderer.
@@ -603,7 +617,7 @@ describe("research runner (async protocol §3)", () => {
// The job is done, with the activity line from the tool call.
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered", "the fake deliver flips the status like the pipeline");
assert.equal(done.activity, "finished", "delivery does not own the worker status");
const result = done.result as ResearchResult;
assert.equal(result.brief, BRIEF);
assert.ok(result.sources.length > 0, "sources parsed from the brief");
@@ -717,6 +731,27 @@ describe("research runner (async protocol §3)", () => {
describe("pdf runner (sources v0)", () => {
const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
const PDF_INPUT = { path: PDF_FIXTURE, question: "What is the integral of x dx?" };
const relativePath = path.join("Course notes", "sample document.pdf");
let home: string;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
before(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-job-home-"));
const target = path.join(home, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(PDF_FIXTURE, target);
process.env.HOME = home;
process.env.USERPROFILE = home;
});
after(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(home, { recursive: true, force: true });
});
/** A pdf-reader that triages, extracts pages 1-2, then briefs. */
function pdfReaderModel(): PiEngine {
@@ -746,7 +781,7 @@ describe("pdf runner (sources v0)", () => {
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.equal(done.activity, "delivered");
assert.equal(done.activity, "finished");
const result = done.result as PdfResult;
assert.equal(result.path, PDF_FIXTURE);
assert.match(result.brief, /integral rule/);
@@ -759,6 +794,54 @@ describe("pdf runner (sources v0)", () => {
assert.ok(steer.content.includes("integral rule"), "steer text carries the brief");
});
for (const mode of ["dispatch", "recovery"] as const) {
it(`resolves a home-relative PDF from ${mode} input without rewriting history`, async () => {
const chat = chatStore.createChat(`Test: relative PDF ${mode}`);
trackChat(chat.id);
const toolCallId = `call-pdf-relative-${mode}`;
const input = { ...PDF_INPUT, path: relativePath };
const messages = nativeToolMessages("read this pdf", "pdf", toolCallId, input);
const before = structuredClone(messages);
chatStore.saveMessages(chat.id, messages);
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId });
trackJob(job.id);
const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
pdfModelOverride.current = pdfReaderModel();
try {
await runner.runJob(job.id, mode === "dispatch" ? input : undefined);
} finally {
pdfModelOverride.current = undefined;
}
// Worker completion is not the delivery barrier: wait for canonical
// acceptance before asserting the history prefix and appended result.
await waitFor(() => jobStore.getJob(job.id)?.delivery?.status === "delivered", 8000, "canonical PDF delivery acknowledgement");
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.deepEqual(done.delivery, { kind: "result", status: "delivered" });
const result = done.result as PdfResult;
assert.equal(result.path, path.join(home, relativePath));
assert.match(result.brief, /integral rule/);
assert.deepEqual(result.pagesRead, [1, 2]);
assert.deepEqual(result.pagesConsulted, [1, 2]);
assert.equal(delivery.steers.length, 1);
assert.equal(input.path, relativePath, "dispatch input remains relative");
const persisted = chatStore.getChat(chat.id)!.messages;
assert.deepEqual(persisted.slice(0, before.length), before, "original history and tool-call arguments remain as supplied");
const steer = delivery.steers[0]!;
assert.equal(steer.id, `steer_${job.id}`);
assert.equal(steer.role, "custom");
assert.equal(steer.customType, "subagent-steer");
const details = steer.details as { jobId: string; kind: string; toolCallId: string; result: PdfResult };
assert.equal(details.jobId, job.id);
assert.equal(details.kind, "pdf");
assert.equal(details.toolCallId, toolCallId);
assert.deepEqual(details.result, result, "the canonical steer carries the resolved PDF result");
assert.deepEqual(persisted.slice(before.length), [steer], "exactly one captured result is appended after the unchanged history");
});
}
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
const chat = chatStore.createChat("Test: pdf runner failure");
trackChat(chat.id);
@@ -827,7 +910,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` });
trackJob(job.id);
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
jobStore.updateJob(job.id, { result, activity: "delivering result" });
jobStore.updateJob(job.id, { result, status: "done", activity: "finished", delivery: { kind: "result", status: "pending" } });
const steer = buildSubagentSteer(job, result);
return { chatId: chat.id, jobId: job.id, steer };
}
@@ -882,7 +965,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after delivery");
assert.equal(jobStore.getJob(jobId)!.status, "done");
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "delivered");
assert.ok(seen, "the turn runner fired");
assert.equal(seen.chatId, chatId);
assert.equal(seen.steers.length, 1);
@@ -920,7 +1003,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
await delay(1200);
assert.equal(fired, 0, "no wake while the quiz is pending");
assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked");
assert.equal(jobStore.getJob(jobId)!.status, "done", "worker is finished while delivery waits");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
// The learner answers: the real tool result clears the dangling call.
const record = chatStore.getChat(chatId)!;
@@ -984,7 +1068,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
await delay(1200);
assert.equal(fired, 0, "no wake while the session sits dismissal-paused");
assert.equal(jobStore.getJob(jobId)!.status, "running", "the delivery holds (queued, not lost)");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending", "the delivery holds (queued, not lost)");
// The user's next message is the wake — its turn settles, and the
// settlement re-pump lands the held delivery right after it.
@@ -1056,7 +1140,8 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => {
await waitFor(() => handed.length === 1, 8000, "marker handed to the live agent");
await delay(1200);
assert.equal(fired, 0, "no wake while the run is live");
assert.equal(jobStore.getJob(jobId)!.status, "running", "job flips only at delivery");
assert.equal(jobStore.getJob(jobId)!.status, "done", "worker finished before delivery");
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
// Settlement: the run persisted the injected marker.
unregisterLiveAgent(chatId);
@@ -1169,20 +1254,15 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => {
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
it("noteSteerVisible flips the job at the visibility seam — before any settle (owner ruling 2026-09-26)", async () => {
// The dock fix: the marker reaching the learner's view (wire
// message_end / write-ahead persist) flips the job NOW — the
// delivering turn may keep teaching for minutes. Idempotent with
// the settle-path finalize.
it("only a durable marker acknowledges delivery, independently of worker completion", () => {
const { chatId, jobId, steer } = setup();
assert.equal(jobStore.getJob(jobId)!.status, "running");
noteSteerVisible(steer);
assert.equal(jobStore.getJob(jobId)!.status, "done", "flipped at the seam, no turn settled");
noteSteerVisible(steer); // the settle-path flip re-runs — no-op
assert.equal(jobStore.getJob(jobId)!.status, "done");
// A probe-handoff (no jobId) is a harmless no-op.
const handoff = buildProbeHandoffSteer(chatId);
noteSteerVisible(handoff);
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, steer]);
noteSteersDurable(chatId, [steer]);
noteSteersDurable(chatId, [steer]);
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "delivered");
assert.equal(jobStore.getJob(jobId)!.activity, "finished");
});
it("the idle wake carries EVERY pending marker in ONE turn", async () => {
@@ -1208,17 +1288,25 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => {
});
it("fails the job (error status) when the wake turn errors unrecoverably", async () => {
const { jobId, steer } = setup();
setTurnRunner(async () => 500);
await deliverJobResult(steer).then(
() => assert.fail("expected delivery to reject"),
() => undefined,
);
const errored = jobStore.getJob(jobId)!;
assert.equal(errored.status, "error");
assert.match(errored.error ?? "", /status 500/);
it("a failed start stays pending without changing the worker outcome", async () => {
const { chatId, jobId, steer } = setup();
let attempts = 0;
setTurnRunner(async () => { attempts++; return 500; });
const delivery = deliverJobResult(steer);
await waitFor(() => attempts === 1, 8000, "failed start attempted");
await delay(1000);
assert.equal(attempts, 1, "no automatic retry loop");
assert.equal(jobStore.getJob(jobId)!.status, "done");
assert.equal(jobStore.getJob(jobId)!.error, undefined);
assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending");
const handed: SteerMessage[] = [];
assert.ok(beginTurn(chatId));
registerLiveAgent(chatId, { steer: (m: SteerMessage) => handed.push(m) } as never);
assert.deepEqual(handed.map((m) => m.id), [steer.id]);
unregisterLiveAgent(chatId);
settleSteers(chatId, [steer]);
endTurn(chatId);
await delivery;
});
});
@@ -1554,14 +1642,14 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
}
});
it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => {
it("a finished worker with a queued result is no longer abortable", async () => {
const chat = chatStore.createChat("Test: abort delivering job");
trackChat(chat.id);
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" });
trackJob(job.id);
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
jobStore.updateJob(job.id, { result, activity: "delivering result" });
jobStore.updateJob(job.id, { result, status: "done", activity: "finished", delivery: { kind: "result", status: "pending" } });
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
assert.ok(beginTurn(chat.id));
@@ -1573,18 +1661,18 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
/* dropped by the abort below */
});
await waitFor(() => jobStore.getJob(job.id)!.activity === "delivering result", 8000, "queued");
assert.equal(jobStore.getJob(job.id)!.delivery?.status, "pending");
const outcome = abortJob(job.id);
assert.ok(outcome.ok);
assert.equal(jobStore.getJob(job.id)!.status, "error");
assert.equal(jobStore.getJob(job.id)!.error, "Aborted by learner");
assert.deepEqual(outcome, { ok: false, reason: "not_running" });
assert.equal(jobStore.getJob(job.id)!.status, "done");
assert.equal(jobStore.getJob(job.id)!.error, undefined);
endTurn(chat.id); // gates open — whatever is left in the queue fires
await waitFor(() => fired.length > 0, 8000, "wake fired");
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
assert.equal((fired[0]!.details as { aborted: boolean }).aborted, true, "the abort steer, not the result");
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
assert.equal(fired.length, 1, "exactly one result delivery");
assert.equal(fired[0]!.customType, "subagent-steer");
assert.equal(jobStore.getJob(job.id)!.status, "done");
});
it("outcome reasons: not_found for unknown, not_running for settled jobs", () => {
+298
View File
@@ -0,0 +1,298 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { SetupDialog } from "../components/setup-dialog.tsx";
import { AgentSection } from "../components/settings/agent-section.tsx";
import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import { useModelSetup, savedSlotsConfigured, selectedModelUnavailable, openModelPicker } from "../components/setup-cta.tsx";
import { StatsBar } from "../components/stats-bar.tsx";
import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
});
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const originalFetch = globalThis.fetch;
let root: Root | undefined;
let host: HTMLDivElement | undefined;
let requests: ReturnType<typeof deferred<Response>>[] = [];
let writes: ModelsSettings[] = [];
let snapshot: ReturnType<typeof useModelCatalog>;
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" };
const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] };
function catalog(connected = true): ModelCatalogResponse {
return {
models: connected ? [a, b] : [], defaults: null,
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null },
slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } },
providers: [{ ...provider, keyPresent: connected }],
};
}
function Reader() { snapshot = useModelCatalog(); return null; }
function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return <SetupDialog open onClose={onClose} onDone={onDone} />; }
function installFetch() {
requests = []; writes = [];
globalThis.fetch = async (input, init) => {
const path = String(input);
if (path === "/api/models") { const request = deferred<Response>(); requests.push(request); return request.promise; }
if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); }
if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] });
if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] });
if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" });
throw new Error(`Unexpected request ${path}`);
};
}
async function render(children: React.ReactNode) {
if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); }
await act(async () => root!.render(children));
}
async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); }
function element(testid: string) { const e = document.querySelector<HTMLElement>(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; }
async function click(testid: string) { await act(async () => element(testid).click()); }
function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); }
async function warm(body = catalog()) { await render(<Reader />); await answer(0, body); }
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => {
installFetch(); await render(setup());
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await answer(0, null);
assert.ok(element("setup-catalog-error"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await click("setup-retry"); assert.ok(element("setup-stage-loading"));
const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null };
await answer(1, empty); assert.ok(element("setup-stage-keys"));
});
test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => {
installFetch(); await warm();
await render(<><Reader />{setup()}</>);
assert.equal(requests.length, 2);
assert.ok(element("setup-stage-pickers"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" };
fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] };
await answer(1, fresh);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾");
await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]);
await click("setup-thinking-strong");
await act(async () => snapshot.refresh()); await answer(2, null);
assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false);
await click("setup-save");
assert.deepEqual(writes[0], {
strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" },
cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" },
vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" },
});
});
test("zero providers always means exclusive connection stage; reconnect with all saved slots only closes, even if every selection remains unavailable", async () => {
installFetch(); await warm();
let closed = 0, completed = 0;
await render(<><Reader />{setup(() => { closed++; }, () => { completed++; })}</>); await answer(1, catalog());
await click("setup-trigger-strong");
await act(async () => notifySettingsSaved("keys"));
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null);
await answer(2, catalog(false));
assert.ok(element("setup-stage-keys"));
assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
assert.equal(document.querySelector('[data-testid="setup-add-provider"]'), null);
assert.equal(document.querySelector('[data-testid="setup-save"]'), null);
await act(async () => notifySettingsSaved("keys"));
const other = catalog(false); other.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
await answer(3, other);
assert.equal(closed, 1); assert.equal(completed, 0); assert.deepEqual(writes, []);
assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null, "no intermediate picker frame after reconnect");
});
test("cold saved disconnected pins still enter the provider stage, without any model-selector frame", async () => {
installFetch(); await warm();
await act(async () => notifySettingsSaved("keys"));
const stages: Array<string | undefined> = [];
function Probe() {
const { catalog } = useModelCatalog();
React.useLayoutEffect(() => { stages.push(document.querySelector<HTMLElement>('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]);
return null;
}
await render(<><Reader />{setup()}<Probe /></>);
await answer(1, catalog(false));
assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-keys"]);
});
for (const count of [0, 1, 2]) test(`connecting with ${count} saved slots only requires the missing slots; stale identities/thinking survive`, async () => {
installFetch(); const before = catalog(false);
if (count < 1) before.slots.strong = null;
if (count < 2) before.slots.cheap = null;
before.slots.vision = null;
if (before.slots.strong) before.slots.strong.thinkingLevel = "xhigh";
await warm(before);
let closed = 0; await render(<><Reader />{setup(() => { closed++; })}</>); await answer(1, before);
assert.ok(element("setup-stage-keys"));
const connected = { ...before, models: [{ ...b, provider: "other" }], providers: [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }] };
await act(async () => notifySettingsSaved("keys")); await answer(2, connected);
assert.ok(element("setup-stage-pickers")); assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); assert.equal(closed, 0);
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) continue;
await click(`setup-trigger-${slot}`); await click("model-option-claude-opus-5-5");
}
if (count > 0) assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾");
await click("setup-save");
for (const slot of ["strong", "cheap", "vision"] as const) {
if (before.slots[slot]) assert.deepEqual(writes[0]?.[slot], before.slots[slot]);
else assert.equal(writes[0]?.[slot]?.model?.provider, "other");
}
});
test("a partial setup's model/thinking edits survive exclusive provider recovery, but reopening discards the abandoned draft", async () => {
installFetch(); const before = catalog(); before.slots.cheap = null; before.slots.vision = null;
await warm(before); await render(<><Reader />{setup()}</>); await answer(1, before);
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
await act(async () => notifySettingsSaved("keys")); await answer(2, { ...before, models: [], providers: [] });
assert.ok(element("setup-stage-keys")); assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null);
await act(async () => notifySettingsSaved("keys")); await answer(3, before);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
await render(<Reader />); await render(<><Reader />{setup()}</>);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); await answer(4, before);
});
test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => {
installFetch(); await warm();
const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } };
let changed: ModelsSettings | undefined;
await render(<><Reader /><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} /></>);
await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]);
assert.equal(element("slot-model-strong").textContent, "model:free▾");
await answer(1, catalog());
await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false));
assert.equal(element("slot-thinking-strong").textContent, "low▾");
await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id);
});
test("saved completeness follows ordinary repairs and survives temporary connection invalidation", async () => {
installFetch(); let state: ReturnType<typeof useModelSetup>;
function StateReader() { state = useModelSetup(false); return null; }
await render(<StateReader />); await answer(0, catalog());
assert.equal(state!.configured, true);
await act(async () => notifySettingsSaved("keys")); assert.equal(state!.configured, true);
const partial = catalog(false); partial.slots.vision = null;
await answer(1, partial); assert.equal(state!.configured, false); assert.equal(state!.zeroKeys, true);
await act(async () => notifySettingsSaved("models")); await answer(2, catalog(false));
assert.equal(state!.configured, true); assert.equal(state!.zeroKeys, true, "saved is not connected");
});
test("blocked session recovery opens its ordinary picker, never changes Strong or substitutes a model", async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
body.slots.strong = { model: { provider: "other", id: b.id }, thinkingLevel: "medium" };
const globals: ModelsSettings = { strong: body.slots.strong };
const session = { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" };
const requestsOnly = globalThis.fetch; const sessionWrites: unknown[] = [];
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return Response.json({ settings: session });
if (input === "/api/chats/fixture/settings") { const next = JSON.parse(String(init?.body)); sessionWrites.push(next); return Response.json({ settings: next }); }
return requestsOnly(input, init);
};
await warm(body); let selected: string | undefined;
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { selected = id; }} /></>);
assert.equal(selected, `${a.provider}:${a.id}`, "current session does not inherit a different Strong pin");
assert.equal(selectedModelUnavailable(body, selected), true);
assert.deepEqual(sessionWrites, []);
await act(async () => openModelPicker());
assert.ok(element("model-menu")); assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null);
assert.equal(document.querySelector('[data-testid="model-option-vendor_model_free"]'), null);
await click("model-option-claude-opus-5-5");
assert.deepEqual(sessionWrites, [{ model: { provider: "other", id: b.id }, thinkingLevel: "xhigh" }]);
assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong });
});
for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => {
installFetch(); const available = { ...b, provider: "other" }; const body = catalog();
body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }];
// Global Strong is unavailable, but this existing session owns a valid
// model. Resolve global settings first while keeping its record pending.
const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } };
let resolveSession!: (response: Response) => void;
let rejectSession!: (reason: Error) => void;
const sessionRead = new Promise<Response>((resolve, reject) => { resolveSession = resolve; rejectSession = reject; });
const requestsOnly = globalThis.fetch;
globalThis.fetch = async (input, init) => {
if (input === "/api/settings") return Response.json({ settings: { models: globals } });
if (input === "/api/chats/fixture") return sessionRead;
return requestsOnly(input, init);
};
await warm(body); const identities: Array<string | undefined> = [];
await render(<><Reader /><StatsBar turns={[]} chatId="fixture" onModelChange={(id) => { identities.push(id); }} /></>);
assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case");
assert.equal(element("stats-model").textContent, "—▾");
assert.equal(identities.at(-1), undefined);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong");
await act(async () => {
if (result === "network-failure") rejectSession(new Error("fixture connection failure"));
else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 }));
else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } }));
});
const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined;
assert.equal(identities.at(-1), expected);
assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin");
assert.equal(document.querySelector('[data-testid="model-menu"]'), null);
assert.deepEqual(writes, []);
});
test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => {
const body = catalog(false);
assert.equal(savedSlotsConfigured(body.slots), true);
assert.equal(selectedModelUnavailable(body, `${a.provider}:${a.id}`), true);
assert.equal(selectedModelUnavailable(undefined, `${a.provider}:${a.id}`), false);
assert.equal(selectedModelUnavailable(body, undefined), false);
body.models = [b]; body.providers = [{ ...provider, keyPresent: true }];
assert.equal(selectedModelUnavailable(body, `${b.provider}:${b.id}`), false);
body.slots.cheap = null; assert.equal(savedSlotsConfigured(body.slots), false);
});
test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => {
installFetch(); await warm();
await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh());
const fresh = catalog(); fresh.models = [b];
await answer(2, fresh); await answer(1, null);
assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); await answer(3, null);
assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false);
await answer(4, fresh);
});
test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => {
const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }];
assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture");
assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 0);
});
+165
View File
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test } from "node:test";
import type { ModelsSettings, ModelSlotName } from "../src/core/settings.ts";
import type { NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts";
// Every app import follows isolation. No installed credentials, ambient keys,
// OAuth, or provider requests: subscription state comes from synthetic IPC.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-models-repair-"));
process.env.LH_DATA_DIR = directory;
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
process.env.LH_IGNORE_ENV_KEYS = "1";
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
process.env.LH_DESKTOP_SERVER = "1";
process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
const { piModels, piEngineForSpec } = await import("../src/agent/models.ts");
const { refreshSubscriptionModels } = await import("../src/agent/subscription-bridge.ts");
const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts");
const { readSettings: readSettingsRecord, writeSettings: writeSettingsRecord } = await import("../src/store/settings.ts");
const { PUT } = await import("../app/api/settings/[section]/route.ts");
const { clampThinkingLevel, normalizeContext } = await import("@earendil-works/pi-ai");
const native = piModels().getModels("github-copilot")[0]!;
const copilot = { provider: "github-copilot-subscription", id: native.id };
const replacement = { provider: "openrouter", id: "z-ai/glm-5.3-flash" };
const slots: ModelSlotName[] = ["strong", "cheap", "vision"];
const initial: ModelsSettings = Object.fromEntries(slots.map((slot) => [slot, { model: copilot, thinkingLevel: "high" }]));
let connected = false, revision = 0, leases = 0;
function snapshot(): NativeSubscriptionSnapshot {
return {
statuses: [{ providerId: "github-copilot", available: true, connected, pending: false, allowBackgroundUse: true, connectionId: connected ? "fixture-connection" : null, epoch: revision, authorityRevision: revision, revision }],
models: { "github-copilot": connected ? [native] : [] },
};
}
Object.defineProperty(process, "send", { configurable: true, value: (message: { id: string; op: string }, callback?: () => void) => {
if (message.op !== "snapshot") { leases++; throw new Error("No credential lease expected"); }
queueMicrotask(() => (process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", id: message.id, op: message.op, ok: true, value: snapshot() }));
callback?.(); return true;
} });
async function connection(value: boolean) { connected = value; revision++; await refreshSubscriptionModels(); }
async function put(body: unknown) {
return PUT(new Request("http://localhost/api/settings/models", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), { params: Promise.resolve({ section: "models" }) });
}
beforeEach(async () => {
await connection(false);
writeSettingsRecord({ version: 1, models: initial, general: { autoUpdate: false }, graph: { rendering: false } });
});
after(() => {
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret]]) {
if (value === undefined) delete process.env[key!]; else process.env[key!] = value;
}
fs.rmSync(directory, { recursive: true, force: true });
});
for (const slot of slots) test(`${slot} can be replaced while the other disconnected slots remain saved`, async () => {
const response = await put({ ...initial, [slot]: { model: replacement, thinkingLevel: "xhigh" } });
assert.equal(response.status, 200, JSON.stringify(await response.clone().json()));
const stored = readSettingsRecord()!;
assert.deepEqual(stored.models?.[slot], { model: replacement, thinkingLevel: "max" });
for (const other of slots.filter((name) => name !== slot)) assert.deepEqual(stored.models?.[other], initial[other]);
assert.deepEqual(stored.general, { autoUpdate: false });
assert.deepEqual(stored.graph, { rendering: false });
assert.deepEqual((await response.json()).settings, stored);
assert(!modelCatalog().models.some((model) => model.provider === copilot.provider), "saving stale pins must not make them available choices");
});
test("all three slots can be repaired successively without reconnecting", async () => {
for (const slot of slots) {
const response = await put({ ...readSettingsRecord()!.models, [slot]: { model: replacement } });
assert.equal(response.status, 200);
}
assert.deepEqual(readSettingsRecord()!.models, Object.fromEntries(slots.map((slot) => [slot, { model: replacement }])));
});
test("thinking preferences on unchanged disconnected models can be changed, removed, and restored", async () => {
for (const thinkingLevel of ["max", undefined, "minimal"] as const) {
const strong = { model: copilot, ...(thinkingLevel ? { thinkingLevel } : {}) };
const response = await put({ ...initial, strong });
assert.equal(response.status, 200);
assert.deepEqual(readSettingsRecord()!.models?.strong, strong);
}
await connection(true);
const engine = piEngineForSpec({ ...copilot, thinkingLevel: "minimal" }, false);
assert.equal(engine.level, clampThinkingLevel(native, "minimal"), "reconnected runtime clamps the saved preference");
assert.equal((await put(readSettingsRecord()!.models)).status, 200);
assert.equal(readSettingsRecord()!.models?.strong?.thinkingLevel, engine.level, "known metadata still clamps on write");
});
test("introduced or replaced unknown models still reject, without writing any section", async () => {
for (const model of [
{ provider: copilot.provider, id: "fixture-not-in-catalog" },
{ provider: "xai-subscription", id: copilot.id },
{ provider: "fixture-unknown-provider", id: copilot.id },
{ provider: replacement.provider, id: "fixture-not-in-catalog" },
]) {
const before = readSettingsRecord();
assert.equal((await put({ ...initial, strong: { model } })).status, 400);
assert.deepEqual(readSettingsRecord(), before);
}
writeSettingsRecord({ version: 1, models: { strong: initial.strong } });
assert.equal((await put({ strong: initial.strong, cheap: initial.strong })).status, 400, "a pin saved in one slot cannot authorize another slot");
assert.equal((await put(initial)).status, 400, "missing slots cannot introduce disconnected models");
});
test("a first models write cannot introduce disconnected selections", async () => {
fs.unlinkSync(path.join(directory, "settings.json"));
assert.equal((await put(initial)).status, 400);
assert.equal(readSettingsRecord(), null);
});
test("a replaced stale selection cannot be reintroduced without availability", async () => {
assert.equal((await put({ ...initial, strong: { model: replacement } })).status, 200);
assert.equal((await put(initial)).status, 400);
assert.deepEqual(readSettingsRecord()!.models?.strong?.model, replacement);
});
test("slot removal still replaces the whole section, including clearing all slots", async () => {
assert.equal((await put({ cheap: initial.cheap })).status, 200);
assert.deepEqual(readSettingsRecord()!.models, { cheap: initial.cheap });
assert.equal((await put({})).status, 200);
assert.deepEqual(readSettingsRecord()!.models, {});
});
test("unchanged model identity does not bypass body shape validation", async () => {
for (const body of [
{ ...initial, strong: { model: copilot, thinkingLevel: 7 } },
{ ...initial, strong: { thinkingLevel: "high" } },
{ ...initial, strong: { model: { provider: copilot.provider } } },
]) assert.equal((await put(body)).status, 400);
assert.deepEqual(readSettingsRecord()!.models, initial);
});
test("standalone catalog validation stays strict without a stored section", () => {
assert.equal(validateModelsSettings(initial).ok, false);
assert.equal(validateModelsSettings({ strong: { model: replacement } }).ok, true);
});
test("retained selections remain unavailable at runtime, with no API-key fallback or credential lease", async () => {
await connection(true);
const engine = piEngineForSpec(copilot, false);
await connection(false);
assert.equal((await put({ ...initial, cheap: { model: replacement } })).status, 200);
assert.throws(() => piEngineForSpec(copilot, false), /catalog has no model/);
let requests = 0;
const before = leases;
const key = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "fixture-must-not-be-used";
try {
const result = await engine.streamFn(engine.model, normalizeContext({ messages: [] }), { fetch: async () => { requests++; throw new Error("Must not dispatch"); } }).result();
assert.equal(result.stopReason, "error");
assert.match(result.errorMessage ?? "", /No API-key fallback was used/);
assert.equal(requests, 0);
assert.equal(leases, before);
} finally {
if (key === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = key;
}
});
+203
View File
@@ -0,0 +1,203 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { SessionModel } from "../components/session-core.ts";
import { newUserMessage } from "../src/core/native-messages.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
describe("new-session opening delivery acceptance", () => {
it("notifies acceptance before the record read or teacher stream completes", async () => {
const record = deferred<Response>();
const accepted = deferred<void>();
const calls: string[] = [];
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
calls.push(String(url));
if (String(url) === "/api/chat") return Response.json({ ok: true });
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
return record.promise;
} });
let completed = false;
const sending = m.send({ type: "text", text: "my syllabus" }, () => accepted.resolve()).then(result => { completed = true; return result; });
await accepted.promise;
assert.equal(completed, false);
assert.equal(m.getState().hasConfirmedConversation, false, "optimistic user text is not canonical proof");
record.resolve(Response.json({ messages: [newUserMessage("my syllabus")] }));
assert.equal(await sending, true);
assert.equal(m.getState().hasConfirmedConversation, true);
assert.equal(calls.filter(url => url === "/api/chat").length, 1);
});
it("a teardown racing the POST still cleans up on acknowledgment without attaching", async () => {
const post = deferred<Response>();
let notified = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
assert.equal(String(url), "/api/chat");
return post.promise;
} });
const sending = m.send({ type: "text", text: "hello" }, () => { notified++; });
m.stop();
post.resolve(Response.json({ ok: true }));
assert.equal(await sending, true);
assert.equal(notified, 1);
});
for (const failure of ["refusal", "409", "lost acknowledgment"] as const) {
it(`${failure} does not notify acceptance or leave a false confirmed conversation`, async () => {
let notified = false;
const m = new SessionModel({ chatId: "c1", initialMessages: [], waitMaxMs: 0, fetch: async () => {
if (failure === "lost acknowledgment") throw new TypeError("connection lost");
return new Response("refused", { status: failure === "409" ? 409 : 503 });
} });
assert.equal(await m.send({ type: "text", text: "keep this" }, () => { notified = true; }), false);
assert.equal(notified, false);
assert.equal(m.getState().hasConfirmedConversation, false);
assert.equal(m.getState().nativeMessages.length, 0, "restore the opening draft, not a stranded optimistic bubble");
assert.equal(m.status, "error");
});
}
for (const failure of ["network", "503"] as const) {
it(`a teardown racing an opening ${failure} failure restores the bubble/error recovery state`, async () => {
const post = deferred<Response>();
let notified = 0;
const envelope = JSON.stringify({ code: "models_not_configured", missing: ["strong"] });
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
assert.equal(String(url), "/api/chat", "failure must not attach or resend");
return post.promise;
} });
const sending = m.send({ type: "text", text: "recover this opening" }, () => { notified++; });
m.stop(); // StrictMode's cleanup detaches the consume, NOT this POST.
if (failure === "network") post.reject(new TypeError("opening connection lost"));
else post.resolve(new Response(envelope, { status: 503 }));
assert.equal(await sending, false);
assert.equal(notified, 0);
assert.equal(m.getState().nativeMessages.length, 0, "no stranded optimistic opening bubble");
assert.equal(m.status, "error");
assert.equal(m.getState().error?.message, failure === "network" ? "opening connection lost" : envelope,
"the raw refusal must reach the existing setup-recovery effect");
});
it(`an older opening ${failure} failure removes only its own bubble while a newer send owns state`, async () => {
const first = deferred<Response>();
const second = deferred<Response>();
let posts = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
assert.equal(String(url), "/api/chat");
return ++posts === 1 ? first.promise : second.promise;
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
const newer = m.send({ type: "text", text: "newer text" });
if (failure === "network") first.reject(new TypeError("older lost acknowledgment"));
else first.resolve(new Response("older refusal", { status: 503 }));
assert.equal(await older, false);
assert.equal(m.status, "submitted", "older failure must not settle the newer send");
assert.equal(m.getState().error, null);
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
second.resolve(new Response("newer failure", { status: 503 }));
assert.equal(await newer, false);
assert.equal(m.getState().error?.message, "newer failure");
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"],
"ordinary text retains its existing optimistic failure presentation");
});
}
it("a newer send remains the owner even after it fails and is stopped", async () => {
const first = deferred<Response>();
let posts = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => {
return ++posts === 1 ? first.promise : new Response("newer failure", { status: 503 });
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
await m.send({ type: "text", text: "newer text" });
m.stop();
first.reject(new TypeError("older failure"));
assert.equal(await older, false);
assert.equal(m.status, "ready");
assert.equal(m.getState().error?.message, "newer failure");
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
});
it("a replacement reattach owns state instead of the older stopped opening POST", async () => {
const post = deferred<Response>();
const record = deferred<Response>();
const saved = newUserMessage("server-owned text");
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
if (String(url) === "/api/chat") return post.promise;
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
return record.promise;
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
const replacement = m.resumeStream();
post.reject(new TypeError("older failure"));
assert.equal(await older, false);
assert.equal(m.status, "submitted");
assert.equal(m.getState().error, null);
record.resolve(Response.json({ messages: [saved] }));
await replacement;
assert.equal(m.status, "ready");
assert.deepEqual(m.getState().nativeMessages, [saved]);
assert.equal(m.getState().error, null);
});
it("a stopped replacement reattach still supersedes the older opening refusal", async () => {
const post = deferred<Response>();
const record = deferred<Response>();
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
return String(url) === "/api/chat" ? post.promise : record.promise;
} });
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
m.stop();
const replacement = m.resumeStream();
m.stop();
post.resolve(new Response("older refusal", { status: 503 }));
assert.equal(await older, false);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
assert.equal(m.getState().nativeMessages.length, 0);
record.resolve(Response.json({ messages: [] }));
await replacement;
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
});
it("a stopped ordinary text failure retains the pre-existing teardown behavior", async () => {
const post = deferred<Response>();
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => post.promise });
const sending = m.send({ type: "text", text: "ordinary text" });
m.stop();
post.reject(new TypeError("connection lost"));
assert.equal(await sending, false);
assert.equal(m.status, "ready");
assert.equal(m.getState().error, null);
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["ordinary text"]);
});
it("post-acceptance attachment failure remains accepted and does not resend input", async () => {
let posts = 0;
let notified = 0;
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); }
throw new TypeError("record read failed");
} });
assert.equal(await m.send({ type: "text", text: "hello" }, () => { notified++; }), true);
assert.equal(notified, 1);
assert.equal(posts, 1);
assert.equal(m.status, "error");
});
it("a busy gate sends nothing, then canonical adoption can confirm an earlier lost acknowledgment", async () => {
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => { throw new Error("must not post"); } });
m.readyRef = false;
assert.equal(await m.send({ type: "text", text: "hello" }, () => assert.fail("must not acknowledge")), false);
m.setMessages([newUserMessage("hello")]);
assert.equal(m.getState().hasConfirmedConversation, true);
});
});
+123
View File
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { clearOpeningPrompt, readOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt } from "../components/opening-prompt.ts";
import { SessionStarter } from "../components/start-session.ts";
const original = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
let data: Map<string, string>;
let storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
function install(value: unknown) {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value });
}
beforeEach(() => {
data = new Map();
storage = {
getItem: key => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, value); },
removeItem: key => { data.delete(key); },
};
install(storage);
});
afterEach(() => {
if (original) Object.defineProperty(globalThis, "sessionStorage", original);
else Reflect.deleteProperty(globalThis, "sessionStorage");
});
const fail = () => { throw new Error("synthetic storage failure"); };
describe("opening message storage", () => {
it("keeps full text off the URL and records attempted delivery before send", () => {
const text = "a syllabus with Unicode 世界 & ? # ".repeat(1500);
assert.equal(stashOpeningPrompt("c1", text), true);
assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: false });
assert.equal(stashOpeningPrompt("c1", text, true), true);
assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: true });
assert.equal(clearOpeningPrompt("c1"), true);
assert.equal(readOpeningPrompt("c1"), undefined);
});
it("treats a legacy raw-text stash conservatively as a possibly attempted send", () => {
data.set("lh.openingPrompt.old", "teach me logic");
assert.deepEqual(readOpeningPromptEntry("old"), { text: "teach me logic", attempted: true });
});
for (const unavailable of [undefined, null]) {
it(`reports unavailable storage (${unavailable}) instead of claiming a safe handover`, () => {
install(unavailable);
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
assert.equal(readOpeningPrompt("c1"), undefined);
assert.equal(clearOpeningPrompt("c1"), false);
});
}
it("catches a throwing storage getter", () => {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, get: fail });
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
assert.equal(readOpeningPrompt("c1"), undefined);
assert.equal(clearOpeningPrompt("c1"), false);
});
for (const method of ["getItem", "setItem", "removeItem"] as const) {
it(`contains ${method} exceptions without crashing a source or chat page`, () => {
storage[method] = fail;
assert.doesNotThrow(() => stashOpeningPrompt("c1", "keep this"));
assert.doesNotThrow(() => readOpeningPrompt("c1"));
assert.doesNotThrow(() => clearOpeningPrompt("c1"));
if (method !== "removeItem") assert.equal(stashOpeningPrompt("c1", "keep this"), false);
});
}
it("does not accept a storage write that failed silently", () => {
storage.setItem = () => {};
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
});
});
describe("new-session create and handover", () => {
function createFetch() {
const bodies: unknown[] = [];
const fetchLike = (async (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Response.json({ id: `chat_${bodies.length}` });
}) as typeof fetch;
return { bodies, fetchLike };
}
it("refuses navigation on quota failure, then reuses the created session for unchanged retry", async () => {
const starter = new SessionStarter();
const { bodies, fetchLike } = createFetch();
const text = "full text ".repeat(4000);
const setItem = storage.setItem;
storage.setItem = fail;
await assert.rejects(starter.start({ title: text }, fetchLike));
assert.equal(bodies.length, 1);
storage.setItem = setItem;
assert.equal(await starter.start({ title: text }, fetchLike), "/chat/chat_1");
assert.equal(bodies.length, 1);
assert.equal(readOpeningPrompt("chat_1"), text);
});
it("reuses creation even when verifying the first storage write failed", async () => {
const starter = new SessionStarter();
const { bodies, fetchLike } = createFetch();
const getItem = storage.getItem;
storage.getItem = fail;
await assert.rejects(starter.start({ title: "hello" }, fetchLike));
storage.getItem = getItem;
assert.equal(await starter.start({ title: "hello" }, fetchLike), "/chat/chat_1");
assert.equal(bodies.length, 1);
});
it("changed text, mode, picks or birth settings create a matching new session", async () => {
const starter = new SessionStarter();
const { bodies, fetchLike } = createFetch();
await starter.start({ title: "original" }, fetchLike, "model-a");
await starter.start({ title: "edited" }, fetchLike, "model-a");
await starter.start({ title: "edited", kind: "practice" }, fetchLike, "model-a");
const picked = { title: "edited", kind: "practice" as const, graphContext: { propositions: [{ id: "p1", name: "P", statement: "S" }] } };
await starter.start(picked, fetchLike, "model-a");
await starter.start(picked, fetchLike, "model-b");
assert.equal(bodies.length, 5);
assert.equal(readOpeningPrompt("chat_1"), undefined);
});
it("does not stash or navigate on a refused creation or invalid ID", async () => {
for (const response of [new Response("unavailable", { status: 503 }), Response.json({}), Response.json({ id: "../bad?prompt=leak" })]) {
const starter = new SessionStarter();
await assert.rejects(starter.start({ title: "hello" }, (async () => response) as typeof fetch));
assert.equal(data.size, 0);
}
});
});
+81 -3
View File
@@ -5,8 +5,10 @@
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { after, before, describe, it } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
@@ -28,10 +30,12 @@ const PNG_1PX = Buffer.from([
0x42, 0x60, 0x82,
]);
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } {
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[]; paths: string[] } {
const calls: string[] = [];
const paths: string[] = [];
return {
calls,
paths,
available: () => true,
unavailableReason: () => null,
runDemo: async () => {
@@ -40,7 +44,8 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp
renderSvg: async () => {
throw new Error("unused");
},
renderPdfPages: async (_pdfPath, pages) => {
renderPdfPages: async (pdfPath, pages) => {
paths.push(pdfPath);
calls.push(`render:${pages.join(",")}`);
rendered.pages.push(...pages);
return pages.map((page) => ({ page, png: PNG_1PX }));
@@ -48,6 +53,79 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp
};
}
describe("pdf-reader source paths", () => {
let home: string;
const relativePath = path.join("Course notes", "sample document.pdf");
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
before(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-home-"));
const target = path.join(home, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(FIXTURE, target);
process.env.HOME = home;
process.env.USERPROFILE = home;
});
after(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(home, { recursive: true, force: true });
});
for (const kind of ["absolute", "home-relative"] as const) {
it(`${kind} path reaches text tools and rendering as the same absolute path`, async () => {
const absolutePath = path.join(home, relativePath);
const inputPath = kind === "absolute" ? absolutePath : relativePath;
assert.equal(os.homedir(), home);
assert.notEqual(path.resolve(relativePath), absolutePath, "cwd is not the learner's home");
const headless = fakeHeadless();
const engine = fauxEngine([
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxToolCall("pdf_search", { query: "integral", literal: true }, { id: "s1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "2" }, { id: "e1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "3" }, { id: "r1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Read the requested document (p. 1, p. 2, p. 3).")]),
]);
let lastContext = "";
const stream = engine.streamFn;
engine.streamFn = (...args) => {
lastContext = JSON.stringify(args[1]);
return stream(...args);
};
const result = await runPdfReaderAgent(
{ path: inputPath, question: "Read the document" },
{ engine, headless },
);
assert.equal(result.path, absolutePath);
assert.match(result.brief, /^Read the requested document/);
assert.match(lastContext, /Pages: 3/, "metadata opened the home-anchored PDF");
assert.deepEqual(result.pagesRead, [2, 3]);
assert.deepEqual(result.pagesConsulted, [1, 2, 3], "search and extraction opened the home-anchored PDF");
assert.deepEqual(headless.paths, [absolutePath]);
});
}
it("a missing home-relative source fails before any model call", async () => {
let modelCalls = 0;
const engine = fauxEngine([]);
engine.streamFn = () => {
modelCalls++;
throw new Error("the model must not be called");
};
const missing = path.join("Course notes", "missing.pdf");
const result = await runPdfReaderAgent({ path: missing, question: "anything" }, { engine });
assert.equal(result.path, path.join(home, missing));
assert.match(result.brief, /^PDF failed:.*ENOENT/);
assert.equal(modelCalls, 0);
assert.deepEqual(result.pagesRead, []);
assert.deepEqual(result.pagesConsulted, []);
});
});
describe("pdf-reader loop (faux engine)", () => {
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
const engine = fauxEngine([
+26 -7
View File
@@ -103,6 +103,16 @@ test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keep
assert.equal(still, "probe");
});
test("phaseAt3 and phaseTimeline: explicit probe refusals never advance the UI phase", () => {
const refused = assistantTool("probe_done", "output-available", { ok: false, error: "question open" });
const errored = assistantTool("probe_done", "output-error", { ok: true });
const tagged = assistantQuiz({ nodeId: "n1" });
const clean = assistantTool("probe_done", "output-available", { ok: true });
const messages = [refused, errored, tagged, clean, tagged];
assert.equal(phaseAt3(messages, 2), "probe");
assert.deepEqual(phaseTimeline(messages), ["probe", "probe", "probe", "plan", "teach"]);
});
test("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => {
const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]);
assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split");
@@ -158,23 +168,31 @@ function assistantQuizCall(arguments_: Record<string, unknown>): LhMessage {
} as unknown as LhMessage;
}
function assistantProbeCall(): LhMessage {
return {
...assistantQuizCall({}),
content: [{ type: "toolCall", id: "call_probe_done", name: "probe_done", arguments: {} }],
} as LhMessage;
}
test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => {
const done = toolResult("probe_done");
const call = assistantProbeCall();
const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" });
const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged quiz call never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged quiz CALL is the seam");
assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged quiz call never starts teaching");
assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged quiz CALL is the seam");
assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)");
assert.equal(
phaseAt3Lh([toolResult("probe_done", true), tagged]),
phaseAt3Lh([call, toolResult("probe_done", true), tagged]),
"probe",
"an ERRORED probe_done concludes nothing",
);
// The two folds agree on the same settled transcript (the view projection
// of the tagged call carries its arguments as the part input).
assert.equal(
phaseAt3Lh([done, tagged]),
phaseAt3Lh([call, done, tagged]),
phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] },
@@ -188,6 +206,7 @@ test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; pla
test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => {
const done = toolResult("probe_done");
const call = assistantProbeCall();
const explainCall = (args: Record<string, unknown>): LhMessage =>
({
id: "a_explain",
@@ -199,10 +218,10 @@ test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too"
const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" });
const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged explain never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged explain CALL is the seam");
assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged explain never starts teaching");
assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged explain CALL is the seam");
assert.equal(
phaseAt3Lh([done, tagged]),
phaseAt3Lh([call, done, tagged]),
phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] },
+61 -2
View File
@@ -13,12 +13,13 @@ import * as path from "node:path";
import { after, describe, it } from "node:test";
import { probeDoneTool } from "../src/agent/tools/probe.ts";
import type { SteerMessage } from "../src/core/contracts.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import { hasProbeDone, type LhMessage } from "../src/core/native-messages.ts";
import { phaseAt3Lh } from "../src/core/phases.ts";
import { HANDOFF_TEXT } from "../src/core/contracts.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
const dataDir = path.join(PATHS.root, "data");
const dataDir = PATHS.data;
fs.mkdirSync(dataDir, { recursive: true });
const createdChats: string[] = [];
@@ -142,3 +143,61 @@ describe("probe_done: the probe→planning seam", () => {
assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam");
});
});
/** A real record result, distinct from the calling assistant message. */
function probeResult(overrides: Partial<Extract<LhMessage, { role: "toolResult" }>> = {}): LhMessage {
return {
id: "probe-result", role: "toolResult", toolCallId: "call_probe", toolName: "probe_done",
content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1,
...overrides,
};
}
const probeCall = () => assistantWithCall("probe", "probe_done", { assessment: ASSESSMENT });
const failedProbeCall = (stopReason: "error" | "aborted"): LhMessage => ({ ...probeCall(), stopReason } as LhMessage);
const incompleteProbes: Array<[string, () => LhMessage[]]> = [
["unmatched call", () => [probeCall()]],
["errored result", () => [probeCall(), probeResult({ isError: true })]],
["explicit refusal without isError", () => [probeCall(), probeResult({ details: { ok: false, error: "refused" } })]],
["result without a call", () => [probeResult()]],
["wrong call id", () => [probeCall(), probeResult({ toolCallId: "other" })]],
["wrong result tool name", () => [probeCall(), probeResult({ toolName: "plan_init" })]],
["wrong call tool name", () => [assistantWithCall("probe", "plan_init", {}), probeResult()]],
["result before the call", () => [probeResult(), probeCall()]],
["failed calling generation", () => [failedProbeCall("error"), probeResult({ details: { ok: true } })]],
["aborted calling generation", () => [failedProbeCall("aborted"), probeResult({ details: { ok: true } })]],
];
describe("probe completion: matched successful results only (PR130 foundation)", () => {
for (const [name, messages] of incompleteProbes) {
it(`${name} keeps probing open and allows a fresh conclusion`, async () => {
const chatId = newChat(`Probe recovery: ${name}`);
const before = messages();
const snapshot = structuredClone(before);
chatStore.saveMessages(chatId, before);
assert.equal(hasProbeDone(before), false);
assert.equal(phaseAt3Lh(before), "probe");
const delivered: SteerMessage[] = [];
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
assert.equal((await execute(tools, { assessment: ASSESSMENT })).ok, true);
assert.equal(delivered.length, 1, "only the fresh invocation queues a handoff");
assert.deepEqual(before, snapshot, "the scans never mutate history");
assert.deepEqual(chatStore.getChat(chatId)!.messages, snapshot, "the tool does not delete or replay saved attempts");
});
}
for (const [name, result] of [
["current acknowledgment", probeResult({ details: { ok: true } })],
["older clean acknowledgment without details", probeResult()],
["older clean acknowledgment without ok", probeResult({ details: { assessment: ASSESSMENT } })],
] as const) {
it(`${name} concludes exactly at the matching result`, () => {
const messages = [probeCall(), result];
const before = structuredClone(messages);
assert.equal(hasProbeDone(messages), true);
assert.equal(phaseAt3Lh(messages, 0), "probe", "a future result cannot conclude an earlier prefix");
assert.equal(phaseAt3Lh(messages, 1), "plan");
assert.deepEqual(messages, before);
});
}
});
+2 -2
View File
@@ -16,7 +16,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative);
const ignored = picomatch(patterns, { contains: true, dot: true, windows });
it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => {
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) {
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local", "chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json", "chatgpt-credentials.json.fixture.tmp", "github-copilot-credentials.json.fixture.tmp", "xai-credentials.json.fixture.tmp"]) {
assert.equal(ignored(file(relative)), true, relative);
}
// The explicit includes remain necessary: this raw ignore still matches SDK data.
@@ -37,7 +37,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false);
});
it("preserves all secret exclusions exactly", () => {
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]);
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", "chatgpt-credentials.json*", "github-copilot-credentials.json*", "xai-credentials.json*", ".env*"]);
});
it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => {
assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [
+27 -2
View File
@@ -78,6 +78,31 @@ function renderDock(props: {
);
}
describe("question submission draft restoration", () => {
const draft = {
selection: { selected: new Set([0]), otherSelected: true, dontKnow: false },
thinking: "preserved reasoning",
other: "preserved custom answer",
text: "preserved free-text answer",
confidenceIndex: 4,
};
it("restores a quiz selection, thinking, and confidence when its form remounts", () => {
const html = renderToStaticMarkup(<QuestionDock toolName="quiz" toolCallId="q1" input={quizInput} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.match(html, /role="radio" aria-checked="true" data-kbdrow="0"/);
assert.ok(html.includes("preserved reasoning"));
assert.ok(html.includes("90%"));
});
it("restores an ask's custom answer and a free-text answer", () => {
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(custom.includes('value="preserved custom answer"'));
assert.match(custom, /aria-checked="true" data-testid="other-option"/);
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(text.includes("preserved free-text answer"));
});
});
describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
const job = (over: Partial<JobRecord>): JobRecord => ({
id: "j1",
@@ -131,9 +156,9 @@ describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
assert.ok(!html.includes("dispatch-refused"));
});
it("a delivered job reads delivered, untouched by the wave", () => {
it("a finished worker reads finished, independently of delivery", () => {
const html = renderToStaticMarkup(inJobs([job({ status: "done", error: undefined })]));
assert.ok(html.includes("delivered"));
assert.ok(html.includes("finished"));
});
});
+56 -4
View File
@@ -2,9 +2,24 @@ import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import { afterEach, beforeEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { DraftShell, startDraftSession } from "../components/new-session.tsx";
import { clearOpeningPrompt, readOpeningPrompt, OPENING_PROMPT_FAILURE } from "../components/opening-prompt.ts";
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
beforeEach(() => {
const data = new Map<string, string>();
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => { data.set(key, value); },
removeItem: (key: string) => { data.delete(key); },
} });
});
afterEach(() => {
if (originalStorage) Object.defineProperty(globalThis, "sessionStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "sessionStorage");
});
/**
* The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session"
@@ -25,10 +40,10 @@ function fakeFetch(id: string) {
}
describe("startDraftSession — the create-on-send contract", () => {
it("creates the session titled with the first message, returns the prompt redirect", async () => {
it("creates the session titled with the first message and returns a short path", async () => {
const { f, calls } = fakeFetch("c_new");
const target = await startDraftSession("teach me orbital mechanics", f);
assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics");
assert.equal(target, "/chat/c_new");
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, "/api/chats");
assert.equal(calls[0]!.method, "POST");
@@ -41,6 +56,37 @@ describe("startDraftSession — the create-on-send contract", () => {
assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side");
});
it("a long opening text stays out of the URL and is stashed for the chat page", async () => {
const mem = new Map<string, string>();
const fake = {
getItem: (k: string) => mem.get(k) ?? null,
setItem: (k: string, v: string) => {
mem.set(k, v);
},
removeItem: (k: string) => {
mem.delete(k);
},
};
const holder = globalThis as { sessionStorage?: Storage };
const prev = holder.sessionStorage;
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: fake });
try {
const { f } = fakeFetch("c_long");
const text = `${"B2 First grammar item. ".repeat(1500)}end`;
const target = await startDraftSession(text, f);
assert.equal(target, "/chat/c_long");
assert.equal(target!.includes("?"), false);
const stashed = readOpeningPrompt("c_long");
assert.equal(stashed?.length, text.length);
assert.equal(stashed, text);
clearOpeningPrompt("c_long");
assert.equal(readOpeningPrompt("c_long"), undefined);
} finally {
if (prev === undefined) delete holder.sessionStorage;
else Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: prev });
}
});
it("empty or whitespace text never creates anything", async () => {
const { f, calls } = fakeFetch("never");
assert.equal(await startDraftSession("", f), null);
@@ -50,6 +96,12 @@ describe("startDraftSession — the create-on-send contract", () => {
});
describe("DraftShell — the sessionless surface", () => {
it("storage failure leaves the full draft editable beside the approved error", () => {
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
assert.ok(html.includes('role="alert"'));
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
assert.match(html, /<textarea[^>]*>keep all my text<\/textarea>/);
});
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" });
});
+49
View File
@@ -19,6 +19,9 @@ import { KeyboardSection } from "../components/settings/keyboard-section.tsx";
import type { GraphSettings } from "../src/core/index.ts";
import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
import { DictationLanguageSetting } from "../components/dictation-language.tsx";
import { SettingsMenuTrigger } 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
@@ -60,6 +63,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 />), "dictation-language"],
] 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("Language uses the standard sans SettingsRow label without changing its mono trigger", () => {
const html = renderToStaticMarkup(<DictationLanguageSetting />);
assert.ok(html.includes('<span class="block font-prose text-(length:--lh-prose-size)">Language</span>'));
assert.ok(tagOf(html, "dictation-language").includes("font-ui text-xs"));
});
it("only Providers subscription labels use sans; setup labels retain their compact mono styling", () => {
const settings = renderToStaticMarkup(<SubscriptionConnections />);
const setup = renderToStaticMarkup(<SubscriptionConnections setup />);
for (const label of ["ChatGPT", "GitHub Copilot", "Grok"]) {
assert.ok(settings.includes(`<span class="font-prose text-(length:--lh-prose-size) text-foreground">${label}</span>`), `${label}: settings sans label`);
assert.ok(setup.includes(`<span class="font-ui text-xs text-foreground">${label}</span>`), `${label}: unchanged setup label`);
}
assert.equal((settings.match(/>Connect<\/button>/g) ?? []).length, 3);
assert.equal((setup.match(/>Connect<\/button>/g) ?? []).length, 3);
});
});
describe("AgentSection (thin SSR)", () => {
it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
+12 -4
View File
@@ -89,9 +89,15 @@ describe("stats bar", () => {
assert.match(empty, /data-testid="stats-context">\?</);
});
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902");
assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free");
assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch");
assert.equal(prettyModel("claude-opus-5"), "claude-opus-5");
assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5");
assert.equal(prettyModel("model:free"), "model:free");
assert.equal(prettyModel("model:variant:free"), "model:variant:free");
});
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
@@ -99,8 +105,10 @@ describe("stats bar", () => {
// the same model via two providers different bookkeeping
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter");
assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai");
// unqualified on a qualified stamp: provider silently dropped (old shape)
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash");
assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter");
assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode");
// Bare ids are never parsed as usage stamps, even with a colon.
assert.equal(prettyModel("model:free"), "model:free");
// qualified on a bare id: no suffix invented
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
});
+8 -7
View File
@@ -1,7 +1,7 @@
/**
* Update-banner render tests (Wave C, 2026-10-10): the banner is a pure
* function of the last lh:update-status push — hidden without one (the
* browser/dev world: no window.lh at all), visible with the downloaded
* function of the shell's observational status — hidden without a bridge
* or snapshot/event, visible with the downloaded
* status, Restart wired to the bridge. The subscribe effect only runs
* client-side, so the SSR pass pins the hidden state; the pushed state is
* exercised by mounting with the bridge stubbed (classic-JSX shim as in
@@ -17,6 +17,7 @@ import * as ReactDOMClient from "react-dom/client";
import { flushSync } from "react-dom";
import { parseHTML } from "linkedom";
import UpdateBanner from "../components/update-banner.tsx";
import type { UpdateState } from "../components/settings/update-settings.tsx";
// linkedom as the DOM for the interactive pass (first createRoot test in
// the repo — the banner's push→render→click flow needs real effects).
@@ -36,9 +37,9 @@ describe("update banner (Wave C)", () => {
});
it("renders nothing when the bridge exists but no status has arrived", () => {
const listeners: Array<(s: { status: string; version: string | null }) => void> = [];
const listeners: Array<(s: UpdateState) => void> = [];
(window as { lh?: unknown }).lh = {
onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => {
onUpdateStatus: (cb: (s: UpdateState) => void) => {
listeners.push(cb);
return () => {};
},
@@ -50,10 +51,10 @@ describe("update banner (Wave C)", () => {
});
it("shows the version + Restart once the downloaded push arrives, and Restart installs", async () => {
let pushed: ((s: { status: string; version: string | null }) => void) | null = null;
let pushed: ((s: UpdateState) => void) | null = null;
let installed = 0;
(window as { lh?: unknown }).lh = {
onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => {
onUpdateStatus: (cb: (s: UpdateState) => void) => {
pushed = cb;
return () => {};
},
@@ -68,7 +69,7 @@ describe("update banner (Wave C)", () => {
assert.equal(host.innerHTML, "", "hidden before the push");
await new Promise((r) => setTimeout(r, 0));
flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1" }));
flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1", revision: 1 }));
assert.ok(host.innerHTML.includes("0.3.1"), "the version renders");
assert.ok(host.innerHTML.includes("Restart"), "the restart affordance renders");
+223
View File
@@ -0,0 +1,223 @@
/** 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 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);
return Response.json({ settings: { ...record, general: body } });
}
return Response.json({ settings: record });
};
const host = await mount(<SettingsDialogBody initialSection="general" onClose={() => {}} />);
await click(q(host, "toggle-auto-update"));
assert.deepEqual(puts, [{ autoUpdate: false, futureSibling: "keep" }]);
assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false");
});
it("the shell's navigation request opens General, but opening alone never checks", async () => {
const b = bridge();
globalThis.fetch = async () => Response.json({ settings: { version: 1 } });
const host = await mount(<SettingsDialogProvider><span>app</span></SettingsDialogProvider>);
assert.equal(q(document, "settings-dialog"), null);
await b.open();
assert.equal(q(document, "settings-nav-general")!.getAttribute("aria-current"), "true");
assert.ok(q(document, "row-version")); assert.equal(b.calls.check, 0);
assert.ok(host.textContent!.includes("app"));
});
});
+8 -8
View File
@@ -12,7 +12,7 @@ import { PracticeDock } from "../components/practice-dock.tsx";
import { QuestionDock } from "../components/question-dock.tsx";
import { LatexGlyph } from "../components/latex-glyph.tsx";
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
import { MessageResponse } from "../components/ai-elements/message.tsx";
import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx";
import {
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
@@ -206,17 +206,17 @@ describe("practice display surfaces", () => {
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
});
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
it("teaching shape parsing retains source bytes; presentation alone recovers them", () => {
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
const ask = { question: doubled };
const explain = { question: doubled, correctAnswer: debris };
const before = structuredClone({ quiz, ask, explain });
assert.equal(parseQuizInput(quiz)?.question, clean);
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
assert.equal(parseAskInput(ask)?.question, clean);
assert.equal(parseExplainInput(explain)?.question, clean);
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
assert.equal(parseQuizInput(quiz)?.question, doubled);
assert.equal(parseQuizInput(quiz)?.explanation, debris);
assert.equal(parseAskInput(ask)?.question, doubled);
assert.equal(parseExplainInput(explain)?.question, doubled);
assert.equal(parseExplainInput(explain)?.correctAnswer, debris);
assertMath(renderToStaticMarkup(<ToolContentResponse>{parseAskInput(ask)!.question}</ToolContentResponse>));
assert.deepEqual({ quiz, ask, explain }, before);
});
+613
View File
@@ -0,0 +1,613 @@
/**
* Failed-response Retry (issue #110). Input is already durable. Retry
* omits the trailing failures from the temporary agent context, never
* deletes them from the transcript, and appends only genuinely new output.
* Scripted faux engines: no real provider calls or learning data.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it, mock } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
import { Type } from "typebox";
import type { AgentBuildOptions, TeachingAgent } from "../src/agent/teacher-agent.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import type { QuestionData } from "../src/core/question-core.ts";
import type { SteerMessage } from "../src/core/contracts.ts";
// Set the storage/auth seams BEFORE any app/config-loading imports.
assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers.");
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-retry-failed-turn-"));
process.env.LH_DATA_DIR = path.join(scratch, "data");
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
process.env.OPENROUTER_API_KEY = "test-key-presence-gate";
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
const { SessionModel } = await import("../components/session-core.ts");
const { createPracticeAgent, createProberAgent, createTeacherAgent } = await import("../src/agent/teacher-agent.ts");
const { handleChatRequest } = await import("../src/server/chat-handler.ts");
const { clearTurnFailure, rememberTurnFailure, runningTurn, turnFailure } = await import("../src/server/turn-runs.ts");
const { isTurnRunning } = await import("../src/server/turn-liveness.ts");
const { chatStore } = await import("../src/store/chats.ts");
const { jobStore } = await import("../src/store/jobs.ts");
const { subscribe } = await import("../src/core/events.ts");
const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts");
const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts");
const { INTERRUPTED_TEXT, asAgentMessages, hasProbeDone, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts");
const { phaseAt3Lh } = await import("../src/core/phases.ts");
const { newCompactionMarker } = await import("../src/core/compaction.ts");
after(() => {
fs.rmSync(scratch, { recursive: true, force: true });
});
const QUIZ_INPUT = {
question: "Which planet is closest to the sun?",
options: [{ label: "Venus" }, { label: "Mercury" }],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
type: "check",
nodeId: "planet-order",
};
const networkFailure: FauxResponseStep = () => { throw new Error("fetch failed"); };
/** Record the actual model context after the app's native conversion. */
function scripted(steps: FauxResponseStep[]) {
const contexts: string[][] = [];
const histories: string[] = [];
const faux = createFauxCore({});
faux.setResponses(steps.map((step) => (context, options, state, model) => {
contexts.push(context.messages.map((m) => m.role).filter((role) => role !== "system"));
histories.push(JSON.stringify(context.messages));
return typeof step === "function" ? step(context, options, state, model) : step;
}));
const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple };
return { engine, contexts, histories };
}
function post(chatId: string, input: unknown): Request {
return new Request("http://test/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input }),
});
}
type AgentFactory = (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent;
/** POST one event; after acceptance drain the server-owned turn. */
async function turn(engine: PiEngine, chatId: string, input: unknown, buildAgent?: AgentFactory): Promise<Response> {
const ack = await handleChatRequest(
post(chatId, input),
buildAgent ?? ((opts) => createTeacherAgent(undefined, { ...opts, engine })),
);
if (ack.status === 200) {
const handle = runningTurn(chatId);
if (handle) await new Response(handle.subscribe()).text();
}
return ack;
}
function createConfiguredChat(title: string, kind?: "practice") {
const chat = chatStore.createChat(title, kind);
chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } });
chatStore.saveTree(chat.id, { nodes: [{ id: "planet-order", title: "Planet order" }], edges: [], version: 1 });
return chat;
}
function gradedAnswer() {
const options = normalizeOptions(QUIZ_INPUT.options);
const qd: QuestionData = {
id: "call-1",
kind: "quiz",
question: QUIZ_INPUT.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(QUIZ_INPUT.correctAnswer, options).indices,
explanation: QUIZ_INPUT.explanation,
createdAt: new Date().toISOString(),
};
return buildAnswer(qd, { values: ["Mercury"], thinking: "Mercury is the innermost", readingThinking: "I followed the planet ordering", confidence: 80 });
}
const roles = (chatId: string) => chatStore.getChat(chatId)!.messages.map((m) => m.role);
const failed = (id: string, text = ""): LhMessage => ({
...fauxAssistantMessage(text, { stopReason: "error", errorMessage: "fetch failed" }), id,
});
const compaction = (firstKeptMessageId: string) => newCompactionMarker("summary", {
firstKeptMessageId, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z",
});
function assertPreserved(before: LhMessage[], saved: LhMessage[]): void {
assert.deepEqual(saved.slice(0, before.length), before, "all previous messages, ids and partial content stay intact");
}
describe("failed-response Retry (issue #110)", () => {
it("retries from the saved answer, preserving the full input and failed attempt", async () => {
const { engine, contexts, histories } = scripted([
() => fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" })], { stopReason: "toolUse" }),
networkFailure,
() => fauxAssistantMessage([fauxText("Right — Mercury it is.")]),
]);
const chat = createConfiguredChat("Test: retry after failed answer");
const answer = gradedAnswer();
await turn(engine, chat.id, { type: "text", text: "teach me the planets" });
await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer });
const before = chatStore.getChat(chat.id)!.messages;
assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]);
assert.ok(endsInFailedTurn(before));
assert.equal((await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer })).status, 400);
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, saved);
assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant", "assistant"]);
assert.equal(endsInFailedTurn(saved), false, "a new successful response supersedes the retained failure");
const results = saved.filter((m) => m.role === "toolResult" && m.toolCallId === "call-1");
assert.equal(results.length, 1, "the learner's answer was not submitted twice");
const result = results[0]!;
assert.ok(result.role === "toolResult");
assert.deepEqual(result.details, JSON.parse(JSON.stringify(answer)));
assert.match(JSON.stringify(saved.at(-1)), /Mercury it is/);
assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]);
assert.match(histories.at(-1)!, /Mercury is the innermost/);
});
it("a failed text turn retries without adding another learner message", async () => {
const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Hello again.")]);
const chat = createConfiguredChat("Test: retry after failed text");
await turn(engine, chat.id, { type: "text", text: "hello" });
const before = chatStore.getChat(chat.id)!.messages;
assert.ok(endsInFailedTurn(before));
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, saved);
assert.deepEqual(roles(chat.id), ["user", "assistant", "assistant"]);
assert.deepEqual(contexts.at(-1), ["user"]);
assert.match(JSON.stringify(saved.at(-1)), /Hello again/);
});
it("preserves partial text and its usage reference before the replacement succeeds", { timeout: 5_000 }, async () => {
const chat = createConfiguredChat("Test: retained partial reply");
const before = [newUserMessage("hello"), failed("partial", "A partial explanation already shown.")];
chatStore.saveMessages(chat.id, before);
const usage = { id: "original-turn", messageId: "partial", at: "2026-01-01T00:00:00Z", model: "faux:fixture", steps: [] };
chatStore.appendTurnUsage(chat.id, usage);
let release!: () => void;
let started!: () => void;
const waiting = new Promise<void>((resolve) => { release = resolve; });
const entered = new Promise<void>((resolve) => { started = resolve; });
const { engine, histories } = scripted([async () => {
started();
await waiting;
return fauxAssistantMessage("Replacement response.");
}]);
const retry = turn(engine, chat.id, { type: "retry" });
await entered;
try {
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before, "write-ahead must not delete the failed attempt");
assert.deepEqual(chatStore.getChat(chat.id)!.turns, [usage]);
assert.doesNotMatch(histories.at(-1)!, /partial explanation/);
} finally {
release();
await retry;
}
const saved = chatStore.getChat(chat.id)!;
assertPreserved(before, saved.messages);
assert.ok(saved.turns?.some((entry) => entry.id === usage.id && entry.messageId === "partial"));
assert.equal(saved.messages.length, before.length + 1, "the persistence offset counts the temporary prefix, not the full saved record");
});
it("keeps every failed attempt across repeated retries, then appends success once", async () => {
const { engine, contexts } = scripted([networkFailure, networkFailure, () => fauxAssistantMessage("Recovered.")]);
const chat = createConfiguredChat("Test: repeated model failure");
await turn(engine, chat.id, { type: "text", text: "hello" });
const first = chatStore.getChat(chat.id)!.messages;
await turn(engine, chat.id, { type: "retry" });
const second = chatStore.getChat(chat.id)!.messages;
assertPreserved(first, second);
assert.equal(second.length, first.length + 1);
assert.ok(endsInFailedTurn(second));
await turn(engine, chat.id, { type: "retry" });
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(second, saved);
assert.equal(saved.length, second.length + 1);
assert.deepEqual(contexts, [["user"], ["user"], ["user"]]);
assert.equal(new SessionModel({ chatId: chat.id, initialMessages: saved }).getState().error, null);
});
it("preserves a trailing plan block while retrying from the preceding input", async () => {
const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Planned reply.")]);
const chat = createConfiguredChat("Test: retry past a plan block");
await turn(engine, chat.id, { type: "text", text: "hello" });
const before = [...chatStore.getChat(chat.id)!.messages, newPlanBlockMessage("plan-1", {})];
chatStore.saveMessages(chat.id, before);
assert.ok(endsInFailedTurn(before));
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
const saved = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, saved);
assert.deepEqual(roles(chat.id), ["user", "assistant", "custom", "assistant"]);
assert.deepEqual(contexts.at(-1), ["user"], "the plan block remains UI-only");
});
it("does not reexecute completed tools before a later model failure", async () => {
let executions = 0;
const { engine, contexts } = scripted([
() => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "note-1" })], { stopReason: "toolUse" }),
networkFailure,
() => fauxAssistantMessage("The saved note is ready."),
]);
const tools: AgentBuildOptions["tools"] = {
save_note: {
name: "save_note", label: "save_note", description: "Record one fixture side effect.", parameters: Type.Object({}),
execute: async () => {
executions++;
return { content: [{ type: "text", text: "Note saved" }], details: { saved: true } };
},
},
};
const factory: AgentFactory = (opts) => createTeacherAgent(undefined, { ...opts, engine, tools });
const chat = createConfiguredChat("Test: no completed tool replay");
await turn(engine, chat.id, { type: "text", text: "save a note" }, factory);
assert.equal(executions, 1);
const before = chatStore.getChat(chat.id)!.messages;
assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]);
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assert.equal(executions, 1, "Retry requests another response, not execution of stored calls");
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]);
});
it("never executes tool calls carried by a failed partial response", async () => {
let executions = 0;
const { engine, contexts } = scripted([
() => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "unexecuted" })], { stopReason: "error", errorMessage: "cut off" }),
() => fauxAssistantMessage("Recovered without running the partial call."),
]);
const factory: AgentFactory = (opts) => createTeacherAgent(undefined, {
...opts, engine,
tools: {
save_note: {
name: "save_note", label: "save_note", description: "Must not execute.", parameters: Type.Object({}),
execute: async () => { executions++; return { content: [], details: {} }; },
},
},
});
const chat = createConfiguredChat("Test: unexecuted partial tool call");
await turn(engine, chat.id, { type: "text", text: "hello" }, factory);
assert.equal(executions, 0);
const before = chatStore.getChat(chat.id)!.messages;
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assert.equal(executions, 0);
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
assert.deepEqual(contexts.at(-1), ["user"]);
});
it("a retained failed probe retries with the prober, concludes once, then selects the teacher", async () => {
const input = { assessment: { strands: [{ strand: "planet order", assessment: "Names known; ordering needs work." }] } };
const { engine, histories } = scripted([
() => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "failed-probe" })], { stopReason: "error", errorMessage: "cut off" }),
() => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "fresh-probe" })], { stopReason: "toolUse" }),
() => fauxAssistantMessage("Assessment ready."),
() => fauxAssistantMessage("Now planning the lesson."),
]);
const chat = createConfiguredChat("Test: failed probe recovery");
const delivered: SteerMessage[] = [];
const selections: boolean[] = [];
const factory: AgentFactory = (opts) => {
selections.push(opts.teaching);
if (opts.teaching) return createTeacherAgent(undefined, { ...opts, engine });
return createProberAgent({
chatId: chat.id, chatStore, jobStore, deliver: (s) => delivered.push(s), spawn: () => {},
}, { ...opts, engine });
};
await turn(engine, chat.id, { type: "text", text: "teach me planet order" }, factory);
const before = chatStore.getChat(chat.id)!.messages;
assert.ok(endsInFailedTurn(before));
assert.equal(hasProbeDone(before), false);
assert.equal(phaseAt3Lh(before), "probe");
assert.equal(delivered.length, 0, "the stored failed call never ran");
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
const recovered = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, recovered);
assert.deepEqual(selections, [false, false], "Retry must still build the prober");
assert.equal(hasProbeDone(recovered), true);
assert.equal(phaseAt3Lh(recovered), "plan");
assert.equal(delivered.length, 1, "the full retained-store guard accepts the fresh conclusion");
assert.deepEqual(recovered.filter((m) => m.role === "toolResult").map((m) => m.role === "toolResult" && m.toolCallId), ["fresh-probe"]);
assert.doesNotMatch(histories[1]!, /failed-probe/, "the failed call is omitted only from temporary Retry history");
assert.equal((await turn(engine, chat.id, { type: "text", text: "go on" }, factory)).status, 200);
assert.deepEqual(selections, [false, false, true]);
assert.equal(delivered.length, 1, "later turns do not replay either saved call");
assertPreserved(recovered, chatStore.getChat(chat.id)!.messages);
});
for (const kind of ["prober", "teacher", "practice"] as const) {
it(`uses the same retained-history retry boundary for the ${kind}`, async () => {
const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]);
const chat = createConfiguredChat(`Test: ${kind} retry`, kind === "practice" ? "practice" : undefined);
const before: LhMessage[] = [newUserMessage("hello")];
if (kind === "teacher") {
before.push({ ...fauxAssistantMessage([fauxToolCall("probe_done", {}, { id: "probe-1" })], { stopReason: "toolUse" }), id: "probe-response" });
before.push({ id: "probe-result", role: "toolResult", toolCallId: "probe-1", toolName: "probe_done", content: [], details: { ok: true }, isError: false, timestamp: 1 });
}
before.push(failed("failed-response", "Partial reply."));
chatStore.saveMessages(chat.id, before);
const factory: AgentFactory = (opts) => {
assert.equal(opts.teaching, kind === "teacher");
assert.ok(opts.messages?.every((m) => m.role !== "assistant" || m.stopReason !== "error"));
if (kind === "practice") return createPracticeAgent({ chatId: chat.id, chatStore }, { ...opts, engine });
if (kind === "prober") return createProberAgent({ chatId: chat.id, chatStore, jobStore, deliver: () => {}, spawn: () => {} }, { ...opts, engine });
return createTeacherAgent(undefined, { ...opts, engine });
};
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
assert.equal(chatStore.getChat(chat.id)!.messages.length, before.length + 1);
assert.deepEqual(contexts.at(-1), kind === "teacher" ? ["user", "assistant", "toolResult"] : ["user"]);
});
}
it("treats an explicit Retry of an automatic main-agent response as foreground", async () => {
const { engine } = scripted([() => fauxAssistantMessage("Explicitly requested response.")]);
const chat = createConfiguredChat("Test: foreground manual retry");
const before = [newUserMessage("hello"), newHandoffMessage(chat.id), failed("automatic-failure")];
chatStore.saveMessages(chat.id, before);
assert.equal(reconstructRequestCause(before), "background");
let built = false;
const factory: AgentFactory = (opts) => {
built = true;
assert.equal(opts.callPurpose?.background, false);
return createTeacherAgent(undefined, { ...opts, engine });
};
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
assert.ok(built);
assertPreserved(before, chatStore.getChat(chat.id)!.messages);
});
});
describe("retained generations and Pi's native provider filtering", () => {
for (const stopReason of ["error", "aborted"] as const) {
it(`does not fabricate results for ${stopReason} partial tool calls on later requests`, () => {
const faux = createFauxCore({});
const partial: LhMessage = {
...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "partial-call" })], { stopReason }), id: "partial",
};
const recovered: LhMessage = { ...fauxAssistantMessage("Recovered."), id: "recovered" };
const messages = [newUserMessage("hello"), partial, recovered, newUserMessage("next question")];
const before = JSON.parse(JSON.stringify(messages));
const converted = toLlmMessages(asAgentMessages(messages));
assert.ok(converted.some((m) => m.role === "assistant" && m.stopReason === stopReason), "the app still leaves failed-generation filtering to Pi");
assert.equal(converted.filter((m) => m.role === "toolResult").length, 0, "no orphan synthetic result for an unexecuted partial call");
const providerHistory = transformMessages(converted, faux.getModel());
assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "user"]);
assert.doesNotMatch(JSON.stringify(providerHistory), /partial-call/);
assert.deepEqual(messages, before, "model conversion never changes the saved attempts");
});
}
it("keeps an actual tool-cancellation result and its completed calling message", () => {
const faux = createFauxCore({});
const call: LhMessage = {
...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "completed-call" })], { stopReason: "toolUse" }), id: "caller",
};
const cancellation: LhMessage = {
id: "cancelled-result", role: "toolResult", toolCallId: "completed-call", toolName: "save_note",
content: [{ type: "text", text: "Tool execution was aborted." }], isError: true, timestamp: 1,
};
const messages = [newUserMessage("hello"), call, cancellation, newUserMessage("continue")];
const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel());
assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]);
const result = providerHistory.find((m) => m.role === "toolResult");
assert.ok(result?.role === "toolResult");
assert.equal(result.toolCallId, "completed-call");
assert.equal(result.isError, true);
assert.deepEqual(result.content, cancellation.content);
});
it("still repairs an unanswered call from a completed generation", () => {
const faux = createFauxCore({});
const question: LhMessage = {
...fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "unanswered-question" })], { stopReason: "toolUse" }), id: "question",
};
const messages = [newUserMessage("hello"), question, newUserMessage("move on")];
const before = JSON.parse(JSON.stringify(messages));
const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel());
assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]);
const result = providerHistory.find((m) => m.role === "toolResult");
assert.ok(result?.role === "toolResult");
assert.equal(result.toolCallId, "unanswered-question");
assert.deepEqual(result.content, [{ type: "text", text: INTERRUPTED_TEXT }]);
assert.deepEqual(messages, before, "the repair is temporary, never persisted");
});
});
describe("Retry refusal and revalidation", () => {
it("rejects an invalid Retry before provider gates, turn claims or writes", async () => {
const chat = chatStore.createChat("Test: retry refused before subscription refresh");
chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "not-a-fixture-model" } });
const before = chatStore.getChat(chat.id)!;
rememberTurnFailure(chat.id, "Existing settlement notice");
const events: string[] = [];
const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); });
try {
const res = await handleChatRequest(post(chat.id, { type: "retry" }), () => { throw new Error("must not construct an agent"); });
assert.equal(res.status, 400, "invalid retry wins even with an unavailable subscription model");
assert.match(await res.text(), /requires a failed turn/);
assert.deepEqual(chatStore.getChat(chat.id), before);
assert.equal(turnFailure(chat.id), "Existing settlement notice", "beginTurn did not clear recovery state");
assert.equal(isTurnRunning(chat.id), false);
assert.deepEqual(events, []);
} finally {
unsubscribe();
clearTurnFailure(chat.id);
}
});
it("rejects Retry after later compaction without removing the error or its boundary", async () => {
const { engine, contexts } = scripted([]);
const chat = createConfiguredChat("Test: compacted failure");
const failure = failed("partial", "Partial explanation.");
const marker = compaction(failure.id);
const before = [newUserMessage("hello"), failure, marker];
chatStore.saveMessages(chat.id, before);
const snapshot = chatStore.getChat(chat.id)!;
const res = await turn(engine, chat.id, { type: "retry" });
assert.equal(res.status, 400);
const body = await res.json() as { error: string };
assert.equal(body.error, "fetch failed\n\nThis response cannot be retried after compaction. Send a new message to continue.");
assert.deepEqual(chatStore.getChat(chat.id), snapshot);
assert.equal(contexts.length, 0);
assert.equal(new SessionModel({ chatId: chat.id, initialMessages: before }).getState().error?.message, "fetch failed");
assert.equal(endsInFailedTurn(before), false);
});
for (const later of ["compaction", "learner-input"] as const) {
it(`revalidates against ${later} landing on the post-compaction reread`, async () => {
const { engine, contexts } = scripted([]);
const chat = createConfiguredChat(`Test: retry race with ${later}`);
const before = [newUserMessage("hello"), failed("failed-response")];
chatStore.saveMessages(chat.id, before);
const appended = later === "compaction" ? compaction("failed-response") : newUserMessage("A newer input");
const read = chatStore.getChat;
let reads = 0;
// Simulate another writer landing during the awaited gate. Do not
// change or invoke the compaction implementation to exercise Retry.
const reader = mock.method(chatStore, "getChat", (id: string) => {
if (id === chat.id && ++reads === 2) chatStore.saveMessages(id, [...before, appended]);
return read(id);
});
const events: string[] = [];
const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); });
try {
const res = await turn(engine, chat.id, { type: "retry" });
assert.equal(res.status, 400);
if (later === "compaction") assert.match(await res.text(), /fetch failed.*cannot be retried after compaction/);
assert.deepEqual(read(chat.id)!.messages, [...before, appended]);
assert.equal(contexts.length, 0);
assert.equal(isTurnRunning(chat.id), false);
assert.deepEqual(events, [], "no turn was claimed after final validation refused");
} finally {
reader.mock.restore();
unsubscribe();
}
});
}
it("a sibling's successful retry makes a later stale Retry refuse without changing history", async () => {
const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Recovered.")]);
const chat = createConfiguredChat("Test: stale sibling Retry");
await turn(engine, chat.id, { type: "text", text: "hello" });
await turn(engine, chat.id, { type: "retry" });
const before = chatStore.getChat(chat.id)!;
assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 400);
assert.deepEqual(chatStore.getChat(chat.id), before);
assert.equal(contexts.length, 2);
});
});
describe("the Retry button's client path", () => {
it("keeps the original model error plus the explanation when compaction wins the click race", async () => {
const messages = [newUserMessage("hello"), failed("failed-response")];
const compacted = [...messages, compaction("failed-response")];
const explanation = "This response cannot be retried after compaction. Send a new message to continue.";
const refused = `fetch failed\n\n${explanation}`;
const m = new SessionModel({
chatId: "chat_x", initialMessages: messages,
fetch: async (_url, init) => {
assert.equal(init?.method, "POST");
assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" });
m.adoptRecord(compacted);
return Response.json({ error: refused }, { status: 400 });
},
});
await m.send({ type: "retry" });
assert.equal(m.getState().status, "error");
assert.equal(m.getState().error!.message, refused, "the banner keeps both messages as readable text, without JSON syntax");
assert.deepEqual(m.getState().nativeMessages, compacted);
assert.equal(endsInFailedTurn(m.getState().nativeMessages), false);
});
for (const { status, body, expected } of [
{ status: 400, body: '{"error":"retry input requires a failed turn to retry"}', expected: "retry input requires a failed turn to retry" },
{ status: 400, body: "Plain refusal", expected: "Plain refusal" },
{ status: 400, body: '{"reason":"not an error envelope"}', expected: '{"reason":"not an error envelope"}' },
{ status: 503, body: '{"error":"model gate","code":"models_not_configured"}', expected: '{"error":"model gate","code":"models_not_configured"}' },
]) {
it(`preserves the appropriate Retry refusal format for ${status}: ${body}`, async () => {
const m = new SessionModel({
chatId: "chat_x", initialMessages: [newUserMessage("hello"), failed("failed-response")],
fetch: async () => new Response(body, { status }),
});
await m.send({ type: "retry" });
assert.equal(m.getState().error?.message, expected);
});
}
it("after a dropped request, Retry posts straight from the error status", async () => {
const failedTurn = [newUserMessage("hello"), failed("a1")];
const posted: string[] = [];
let online = false;
const fetch = (async (_url: string, init?: RequestInit) => {
if (!online) throw new TypeError("Failed to fetch");
if (init?.method === "POST") posted.push(String(init.body));
return new Response("{}", { status: 500 });
}) as typeof globalThis.fetch;
const m = new SessionModel({ chatId: "chat_x", initialMessages: failedTurn, fetch });
await m.send({ type: "retry" });
assert.equal(m.getState().status, "error");
online = true;
await m.send({ type: "retry" });
assert.equal(posted.length, 1, "send() recovers from error without stop()");
assert.deepEqual(JSON.parse(posted[0]!).input, { type: "retry" });
assert.deepEqual(m.getState().nativeMessages, failedTurn, "there is no optimistic learner message or deletion");
});
});
describe("Retry eligibility and temporary omissions (pure)", () => {
it("parses Retry without appending input", () => {
const input = parseChatInput({ type: "retry" });
assert.deepEqual(input, { type: "retry" });
assert.deepEqual(inputAppends({ messages: [newUserMessage("hi"), failed("a1")] }, input), []);
});
it("omits consecutive failures back to their input without mutating the record", () => {
const messages = [newUserMessage("hi"), failed("a1"), failed("a2")];
const before = JSON.parse(JSON.stringify(messages));
assert.deepEqual([...retryDrops(messages)].sort(), ["a1", "a2"]);
assert.deepEqual(messages, before);
});
it("looks past plan blocks, but compaction after the latest failure blocks Retry", () => {
const user = newUserMessage("hi");
const failure = failed("a1");
const block = newPlanBlockMessage("plan-1", {});
const marker = compaction(user.id);
assert.deepEqual([...retryDrops([user, failure, block])], ["a1"]);
assert.equal(endsInFailedTurn([user, block]), false);
for (const messages of [[user, failure, marker], [user, failure, marker, block], [user, failure, block, marker]]) {
assert.equal(endsInFailedTurn(messages), false);
assert.equal(failedResponseAtEnd(messages)?.id, failure.id, "only the Retry button disappears, not the banner");
assert.throws(() => retryDrops(messages), /fetch failed[\s\S]*cannot be retried after compaction/);
}
assert.deepEqual([...retryDrops([user, failed("old"), marker, failed("new")])], ["new"], "a failure after compaction can retry its already-compacted starting state");
});
it("excludes aborts, healthy responses, missing input and assistant-only predecessors", () => {
const user = newUserMessage("hi");
const aborted = { ...failed("aborted"), stopReason: "aborted" } as LhMessage;
const healthy = { ...fauxAssistantMessage("Done"), id: "healthy" } as LhMessage;
for (const messages of [[user], [failed("a1")], [user, aborted], [user, healthy], [healthy, failed("a1")]]) {
assert.equal(endsInFailedTurn(messages), false);
assert.throws(() => retryDrops(messages), ChatInputError);
}
assert.equal(failedResponseAtEnd([user, failed("old"), aborted]), undefined);
});
});
+1 -1
View File
@@ -110,7 +110,7 @@ test("render-settings: its write stamps the family sidecar too", () => {
// ── the fixtures stay the legacy pin: golden data reads unstamped ───────────
test("golden fixtures are deliberately the unstamped legacy shape (lazy v1 = current-valid)", () => {
const here = path.dirname(new URL(import.meta.url).pathname);
const here = import.meta.dirname;
const teach = JSON.parse(fs.readFileSync(path.join(here, "fixtures", "golden-data", "chats", "chat_mv27rrra_feff23b6.json"), "utf8"));
assert.equal(teach.version, undefined, "fixture records pin the unstamped read path");
assert.ok(!fs.existsSync(path.join(here, "fixtures", "golden-data", "graph", "format.json")), "no sidecar in the fixture graph dir");
+366
View File
@@ -0,0 +1,366 @@
/** 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 { 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],
["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));
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);
});
});
+357 -6
View File
@@ -25,7 +25,7 @@ import { absorbed, pendingCallTool, SessionModel } from "../components/session-c
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 { 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,334 @@ 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("adopts a changed pending question instead of sending the stale answer", async () => {
let posts = 0;
const newer = { ...question, id: "new-question", content: [{ type: "toolCall", id: "q2", name: "quiz", arguments: {} }] } as LhMessage;
const m = model({ initialMessages: [question], fetch: async (url) => {
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
return Response.json({ messages: [question, newer], turnInFlight: false });
} });
await m.submitAnswer("quiz", "q1", answer);
await m.submitAnswer("quiz", "q1", answer);
assert.equal(posts, 1);
assert.deepEqual(m.getState().nativeMessages, [question, newer]);
assert.equal(m.status, "ready");
});
});
// ─── reattach (resumeStream) ────────────────────────────────────────────────
describe("resumeStream (the reattach path)", () => {
+2 -2
View File
@@ -234,12 +234,12 @@ 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.
assert.deepEqual(
Object.keys(SETTINGS_SECTIONS),
["general", "chat", "agent", "appearance", "graph", "compaction", "models"],
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
);
});
});
+1 -1
View File
@@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
});
it("setupStage: keys before models (owner's dependency ordering)", () => {
assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback");
assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing");
assert.equal(setupStage([]), "keys", "no providers = keys");
assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless");
assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");
+2 -2
View File
@@ -42,7 +42,7 @@ for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, tru
const chat = stores.chatStore.createChat("Fixture mixed queue"); const model = { ...piModels().getModels(p.providerId)[0]!, provider: p.logicalId };
stores.chatStore.saveSettings(chat.id, { model: { provider: p.logicalId, id: model.id } });
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "fixture-job-call" }); const result = { brief: "Fixture helper result", sources: [] };
stores.jobStore.updateJob(job.id, { status: "running", result }); const steer = buildSubagentSteer(job, result); const contexts: string[] = [];
stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } }); const steer = buildSubagentSteer(job, result); const contexts: string[] = [];
const agent = new Agent({ initialState: { model, messages: [{ id: "fixture-initial", role: "user", content: "Initial learner input", timestamp: 1 } as AgentMessage] }, convertToLlm: toLlmMessages, streamFn: () => {
contexts.push(JSON.stringify(agent.state.messages)); const stream = new AssistantMessageEventStream();
const output = { role: "assistant" as const, provider: p.logicalId, api: model.api, model: model.id, content: [{ type: "text" as const, text: "Foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
@@ -57,6 +57,6 @@ for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, tru
assert.equal(contexts.length, 1); assert(contexts[0]!.includes("Continue my learning")); assert(!contexts[0]!.includes(steer.id));
const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted); await new Promise((resolve) => setImmediate(resolve));
assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "running");
assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "done"); assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending");
connection(p.providerId, true); await delivered; assert.equal(wakes, 1); assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
});
+207
View File
@@ -0,0 +1,207 @@
import React, { act, useState } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { VisualOutputCard } from "../components/artifacts/visual-card.tsx";
import { svgImageSource } from "../components/artifacts/svg-image.tsx";
import { MermaidOverlay, SessionMermaidContext } from "../components/session-overlay.tsx";
// Structure-only checks: linkedom does not load images or implement CSS cascade.
// No fixture script, link, or external reference is executed by these tests.
const FIGURE = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160" viewBox="0 0 320 160"><style>text{font-family:Arial,Helvetica,sans-serif;fill:#202830}</style><defs><marker id="arrow"><path d="M0 0L5 5"/></marker></defs><path marker-end="url(#arrow)" d="M0 0L20 20"/><text>θ &amp; φ: 50% #1</text></svg>`;
const ACTIVE_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" onload="example()"><script>example()</script><foreignObject><div xmlns="http://www.w3.org/1999/xhtml" onclick="example()">Example</div></foreignObject><a href="javascript:example()"><text>Link</text></a><image href="https://example.invalid/image.png"/></svg>`;
const PREFIX = "data:image/svg+xml;charset=utf-8,";
function assertImageBoundary(holder: Element, source: string) {
const image = holder.querySelector("img");
assert.ok(image, "figure is an image");
assert.equal(holder.children.length, 1, "no figure markup alongside the image");
assert.equal(holder.querySelector("svg, style, script, foreignObject, a, [onload], [onclick]"), null,
"generated elements and CSS never enter the app document");
assert.equal(image.getAttribute("alt"), "Figure");
assert.equal(image.getAttribute("draggable"), "false", "native image dragging must not interfere with panning");
const src = image.getAttribute("src")!;
assert.ok(src.startsWith(PREFIX));
assert.equal(decodeURIComponent(src.slice(PREFIX.length)), source, "image URL contains the expected SVG display source, not a raster");
return image;
}
describe("SVG display compatibility", () => {
it("leaves standalone SVG unchanged, including comments and CDATA", () => {
const source = FIGURE.replace("</svg>", '<!-- &nbsp; <svg> --><![CDATA[&nbsp; <svg>]]></svg>');
assert.equal(svgImageSource(source), source);
});
it("adds only a missing root namespace, ignoring comments and attribute values", () => {
const source = '<?xml version="1.0"?><!-- <svg xmlns="wrong"> --><svg data-note=\' xmlns="not-a-declaration" >\' xmlns:xlink="http://www.w3.org/1999/xlink"><svg/></svg>';
const expected = source.replace('<svg data-note=', '<svg xmlns="http://www.w3.org/2000/svg" data-note=');
assert.equal(svgImageSource(source), expected);
assert.equal(svgImageSource(expected), expected, "normalization is idempotent");
});
it("does not replace an explicit namespace declaration", () => {
for (const declaration of ['xmlns="http://www.w3.org/2000/svg"', "xmlns = 'other'", 'xmlns=""']) {
const source = `<svg ${declaration}><text>Example</text></svg>`;
assert.equal(svgImageSource(source), source);
}
});
it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => {
const source = '<?example &nbsp;?><svg xmlns="http://www.w3.org/2000/svg" aria-label="A&nbsp;B"><text>A&nbsp;B &amp;nbsp; &#160;</text><!-- &nbsp; --><![CDATA[&nbsp;]]></svg>';
const expected = source.replace('aria-label="A&nbsp;B"', 'aria-label="A&#160;B"').replace('<text>A&nbsp;B', '<text>A&#160;B');
assert.equal(svgImageSource(source), expected);
assert.equal(svgImageSource(expected), expected);
});
});
describe("generated SVG image boundary", () => {
for (const mode of ["inline", "fullscreen"] as const) {
for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) {
it(`${mode}: isolates ${name} inside the image URL`, () => {
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={{ svg: source }} />
: <MermaidOverlay svg={source} onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assertImageBoundary(holder, source);
assert.ok(holder.classList.contains("bg-white"), "white backing retained");
});
}
for (const [name, original, display] of [
["missing namespace", '<svg width="320" height="160"><text>Example</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160"><text>Example</text></svg>'],
["HTML entity", '<svg xmlns="http://www.w3.org/2000/svg"><text>A&nbsp;B</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg"><text>A&#160;B</text></svg>'],
] as const) {
it(`${mode}: repairs ${name} only in the image source`, () => {
const output = { svg: original };
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={output} />
: <MermaidOverlay svg={output.svg} onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assertImageBoundary(holder, display);
assert.equal(output.svg, original, "artifact source was not mutated");
});
}
it(`${mode}: retains inversion on the white backing`, () => {
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={{ svg: FIGURE }} initialInverted />
: <MermaidOverlay svg={FIGURE} inverted onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
assert.ok(holder.classList.contains("bg-white"));
assertImageBoundary(holder, FIGURE);
});
}
it("inline: retains natural width and only shrinks oversized figures", () => {
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: FIGURE }} />));
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE);
assert.equal(image.getAttribute("width"), "320");
assert.equal(image.className, "block h-auto max-w-full");
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit"));
});
for (const width of ["", ' width="100%"']) {
it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => {
const source = `<svg xmlns="http://www.w3.org/2000/svg"${width} viewBox="0 0 320 160"><text>Example</text></svg>`;
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: source }} />));
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source);
assert.equal(image.getAttribute("width"), null);
assert.equal(image.className, "block h-auto w-full");
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full"));
});
}
it("fullscreen: keeps the natural-size box and zoom controls", () => {
const { document } = parseHTML(renderToStaticMarkup(<MermaidOverlay svg={FIGURE} onClose={() => {}} />));
const holder = document.querySelector("[data-lh-overlay-chart]")!;
assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full");
assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/);
for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) {
assert.ok(document.querySelector(`button[aria-label="${label}"]`));
}
});
});
it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => {
const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ &amp; φ", "θ&nbsp;&amp; φ");
const displaySvg = FIGURE.replace("θ &amp; φ", "θ&#160;&amp; φ");
const dom = parseHTML("<html><body></body></html>");
const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const;
const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
Object.assign(globalThis, {
window: dom.window,
document: dom.document,
Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
const host = dom.document.createElement("div");
dom.document.body.append(host);
const root = createRoot(host);
const originalCreateUrl = URL.createObjectURL;
const originalRevokeUrl = URL.revokeObjectURL;
const originalCreateElement = dom.document.createElement;
let downloaded: Blob | undefined;
let revoked: string | undefined;
let clicked: { name: string; href: string } | undefined;
URL.createObjectURL = (blob) => {
assert.ok(blob instanceof Blob);
downloaded = blob;
return "blob:figure-test";
};
URL.revokeObjectURL = (url) => { revoked = url; };
function Harness() {
const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null);
return (
<SessionMermaidContext.Provider value={{ openMermaid: () => {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}>
<VisualOutputCard output={{ svg: originalSvg }} initialInverted />
{focus && <MermaidOverlay svg={focus.svg} inverted={focus.inverted} onClose={() => setFocus(null)} />}
</SessionMermaidContext.Provider>
);
}
try {
await act(async () => root.render(<Harness />));
await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg);
const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!;
const fullscreen = assertImageBoundary(overlayHolder, displaySvg);
assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src"));
assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
// Intercept only the download anchor; never navigate or write a real file.
dom.document.createElement = ((...args: Parameters<typeof originalCreateElement>) => {
const element = originalCreateElement.apply(dom.document, args);
if (args[0] === "a") {
element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; };
}
return element;
}) as typeof originalCreateElement;
await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
assert.ok(downloaded);
assert.equal(await downloaded.text(), originalSvg);
assert.equal(downloaded.type, "image/svg+xml");
assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" });
assert.equal(revoked, "blob:figure-test");
} finally {
try {
await act(async () => root.unmount());
} finally {
URL.createObjectURL = originalCreateUrl;
URL.revokeObjectURL = originalRevokeUrl;
dom.document.createElement = originalCreateElement;
host.remove();
for (const [i, name] of names.entries()) {
if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!);
else Reflect.deleteProperty(globalThis, name);
}
}
}
});
+78
View File
@@ -0,0 +1,78 @@
import React, { act } from "react";
(globalThis as { React?: typeof React }).React = React;
import assert from "node:assert/strict";
import { test } from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { QuestionDock } from "../components/question-dock.tsx";
import { AnsweredCard } from "../components/answered-card.tsx";
import { newAnswerResult, type QuestionAnswerData } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
test("actual select → submit → saved result → historical Why uses source data and presentation-only recovery", async () => {
const question = String.raw`Find $\\nabla f$ for $\begin{pmatrix}a&b\\c&d\end{pmatrix}$.`;
const label = String.raw`$\\nabla f$`;
const explanation = String.raw`First paragraph.\n\nThe matrix is $\begin{pmatrix}p&q\\r&s\end{pmatrix}$ and $\\nabla f$ is its gradient.`;
const input = { question, options: [{ label }, { label: "no" }], correctAnswer: label, explanation };
const before = structuredClone(input);
let answer: QuestionAnswerData | undefined;
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
await act(async () => root.render(<QuestionDock toolName="probe" toolCallId="interactive_source" input={input} expanded
dictationClaim={false} onExpand={() => {}} onSubmit={a => { answer = a; }} />));
assert.equal(host.querySelectorAll("mtr").length, 2, "the live matrix retains two rows");
const choice = [...host.querySelectorAll<HTMLButtonElement>('[role="radio"]')].find(button => button.querySelector(".katex"))!;
assert.ok(choice);
await act(async () => choice.click());
assert.equal(choice.getAttribute("aria-checked"), "true");
const submit = [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent?.includes("Submit answer"))!;
assert.equal(submit.disabled, false);
// linkedom does not implement the browser's default button→form
// action; dispatch its native submit event through React instead.
await act(async () => submit.closest("form")!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })));
assert.ok(answer);
assert.equal(answer.questionText, question);
assert.equal(answer.explanation, explanation);
assert.equal(answer.answers[0]!.value, label, "display recovery must not alter the submitted identity");
assert.equal(answer.answers[0]!.label, label);
assert.equal(answer.correct, true);
const persisted = JSON.parse(JSON.stringify(newAnswerResult("interactive_source", "probe", answer)));
assert.deepEqual(persisted.details, JSON.parse(JSON.stringify(answer)));
assert.equal(persisted.details.explanation, explanation);
const saved = structuredClone(persisted.details);
await act(async () => root.render(<AnsweredCard answer={persisted.details} input={input} />));
const why = [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Why")!;
await act(async () => why.click());
assert.equal(host.querySelectorAll("mtr").length, 4, "question and revealed explanation retain their rows");
const paragraphs = host.querySelectorAll('[data-testid="tucked-explanation"] p');
assert.equal(paragraphs.length, 2, "recovered paragraphs remain distinct in Why");
assert.ok(paragraphs[0]!.parentElement!.classList.contains("[&>p:first-child]:inline"));
assert.ok(!paragraphs[0]!.parentElement!.classList.contains("[&_p]:inline"));
const annotations = [...host.querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent);
assert.equal(annotations.filter(t => t === String.raw`\nabla f`).length, 3);
assert.deepEqual(persisted.details, saved);
assert.deepEqual(input, before);
} finally { await act(async () => root.unmount()); host.remove(); }
});
test("committed-answer reveals keep the first paragraph inline without flattening later paragraphs", async () => {
const committed = String.raw`First paragraph.\n\nSecond paragraph with $\\nabla f$ and $\nu$.`;
const answer: QuestionAnswerData = { kind: "explain", questionText: "Explain the gradient.", answers: [], text: "Synthetic answer", committed, dontKnow: false, answeredAt: "2026-10-26T00:00:00.000Z" };
const input = { question: answer.questionText, correctAnswer: committed };
const before = structuredClone(answer);
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
await act(async () => root.render(<AnsweredCard answer={answer} input={input} />));
const paragraphs = host.querySelectorAll("p");
assert.equal(paragraphs.length, 3, "question plus two committed-answer paragraphs");
assert.ok(paragraphs[1]!.parentElement!.classList.contains("[&>p:first-child]:inline"));
assert.ok(!paragraphs[1]!.parentElement!.classList.contains("[&_p]:inline"));
assert.equal(host.querySelectorAll(".katex-error").length, 0);
assert.deepEqual(answer, before);
} finally { await act(async () => root.unmount()); host.remove(); }
});
+164
View File
@@ -0,0 +1,164 @@
import React from "react";
(globalThis as { React?: typeof React }).React = React;
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx";
import { QuestionDock } from "../components/question-dock.tsx";
import { AnsweredCard } from "../components/answered-card.tsx";
import { answerForPresentation, buildAnswer, normalizeOptions, parseAskInput, parseExplainInput, parseQuizInput,
repairMathForDisplay, resolveCorrect, type QuestionAnswerData, type QuestionData } from "../src/core/index.ts";
const fixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/matrix-regressions.json", import.meta.url), "utf8")) as {
cases: Array<{ chatId: string; source: string; raw: string; oldDisplay: string }>;
};
const a = String.raw`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`;
const b = String.raw`$\begin{pmatrix}p&q\\r&s\end{pmatrix}$`;
const brokenA = a.replace(String.raw`\\c`, String.raw`\c`);
const brokenB = b.replace(String.raw`\\r`, String.raw`\r`);
const doubled = String.raw`$\\nabla f$`;
const input = { question: a, options: [{ label: a, value: "a" }, { label: b, value: "b" }], correctAnswer: "a", explanation: a };
const dom = (html: string) => parseHTML(`<html><body>${html}</body></html>`).document;
const render = (text: string, recovered = true) => renderToStaticMarkup(
recovered ? <ToolContentResponse mode="static">{text}</ToolContentResponse> : <MessageResponse mode="static">{text}</MessageResponse>);
const tex = (html: string) => [...dom(html).querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent);
for (const fixture of fixtures.cases) test(`preserves corpus matrix rows: ${fixture.chatId} ${fixture.source}`, () => {
assert.notEqual(fixture.raw, fixture.oldDisplay);
assert.equal(repairMathForDisplay(fixture.raw), fixture.raw);
const expected = dom(render(fixture.raw, false));
const actual = dom(render(fixture.raw));
assert.deepEqual(tex(actual.body.innerHTML), tex(expected.body.innerHTML));
assert.equal(actual.querySelectorAll("mtr").length, expected.querySelectorAll("mtr").length);
assert.ok(actual.querySelectorAll("mtr").length > dom(render(fixture.oldDisplay, false)).querySelectorAll("mtr").length);
assert.equal(actual.querySelectorAll(".katex-error").length, 0);
});
test("generic nested environments, comments, and bare row-leading command names are protected", () => {
for (const environment of ["matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "smallmatrix", "array", "aligned", "cases"]) {
const source = `$\\begin{${environment}}a&b\\\\beta&d\\end{${environment}}$`;
assert.equal(repairMathForDisplay(source), source);
}
const nested = "$$\n" + String.raw`\begin{aligned}x&=\begin{pmatrix}a&b\\c&d\end{pmatrix}\\% \end{aligned} is a comment` + "\n" + String.raw`r&=s\end{aligned}` + "\n$$";
assert.equal(repairMathForDisplay(nested), nested);
const spaced = String.raw`$\begin {pmatrix}a&b\\c&d\end {pmatrix}$`;
assert.equal(repairMathForDisplay(spaced), spaced);
const mixed = String.raw`$\\lambda+\begin{pmatrix}a&b\\c&d\end{pmatrix}$`;
assert.equal(repairMathForDisplay(mixed), mixed.replace(String.raw`\\lambda`, String.raw`\lambda`));
});
test("corpus document framing and final-display percentages recover without new errors", () => {
const cases = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/document-percent-regressions.json", import.meta.url), "utf8"));
for (const fixture of cases) {
const recovered = repairMathForDisplay(fixture.raw);
const document = dom(render(fixture.raw));
assert.equal(document.querySelectorAll(".katex-error").length, 0, fixture.chatId);
assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId);
if (fixture.kind === "percent-at-display-end") {
assert.ok([...document.querySelectorAll(".katex-html")].some(node => node.textContent?.includes("%")), fixture.chatId);
assert.ok(!recovered.includes("\\\\%"), fixture.chatId);
} else {
assert.match(recovered, /\n\$\$\n/);
assert.match(recovered, /\n\$\$$/);
}
}
});
test("mixed-escaped environment markers retain existing recovery rather than treating an opener as native", () => {
const mixedFixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/mixed-escaping-regressions.json", import.meta.url), "utf8")) as typeof fixtures;
assert.equal(mixedFixtures.cases.length, 2);
for (const fixture of mixedFixtures.cases) {
const recovered = repairMathForDisplay(fixture.raw);
assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId);
assert.doesNotMatch(recovered, /\\\\end\{/);
assert.equal(dom(render(fixture.raw)).querySelectorAll(".katex-error").length, 0);
}
});
test("presentation-equivalent labels cannot make a source-invalid correctAnswer gradeable", () => {
const source = { question: "Which?", options: [{ label: String.raw`$\nabla f$` }, { label: "no" }],
correctAnswer: doubled, explanation: "A model-authored identity mismatch" };
const before = structuredClone(source);
assert.equal(repairMathForDisplay(doubled), source.options[0]!.label);
assert.equal(parseQuizInput(source), undefined);
assert.deepEqual(source, before);
});
test("shape parsers retain source text and identities; recovery is presentation only", () => {
const quiz = { ...input, question: doubled, explanation: "$\forall x$", options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled };
const ask = { question: doubled, options: [{ label: doubled, value: doubled, description: doubled }, { label: "no" }] };
const explain = { question: doubled, correctAnswer: "$\forall x$" };
const before = structuredClone({ quiz, ask, explain });
assert.equal(parseQuizInput(quiz)!.question, doubled);
assert.equal(parseQuizInput(quiz)!.explanation, quiz.explanation);
assert.equal(parseQuizInput(quiz)!.correctAnswer, doubled);
assert.deepEqual(parseAskInput(ask), ask);
assert.deepEqual(parseExplainInput(explain), explain);
assert.deepEqual(tex(render(doubled)), [String.raw`\nabla f`]);
assert.deepEqual(tex(render(doubled, false)), [String.raw`\\nabla f`]);
assert.deepEqual({ quiz, ask, explain }, before);
});
test("the live dock recovers only its rendering, including question, context and option labels", () => {
const source = { question: doubled, details: doubled, options: [{ label: doubled }, { label: "no" }] };
const before = structuredClone(source);
const html = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="source_q" input={source} expanded onExpand={() => {}} onSubmit={() => {}} />);
assert.deepEqual(tex(html), Array(3).fill(String.raw`\nabla f`));
assert.deepEqual(source, before);
});
test("answers snapshot source text, not recovered labels or identities", () => {
const source = { ...input, question: doubled, explanation: doubled, options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled };
const parsed = parseQuizInput(source)!;
const options = normalizeOptions(parsed.options);
const qd: QuestionData = { id: "source", kind: "quiz", question: parsed.question, mode: "single-select", options,
correctIndices: resolveCorrect(parsed.correctAnswer, options).indices, explanation: parsed.explanation, createdAt: "synthetic" };
const answer = buildAnswer(qd, { values: [doubled], confidence: 70 }, "probe");
assert.equal(answer.questionText, doubled);
assert.equal(answer.options![0]!.label, doubled);
assert.equal(answer.answers[0]!.value, doubled);
assert.equal(answer.answers[0]!.label, doubled);
assert.equal(answer.explanation, doubled);
assert.equal(answer.correct, true);
const before = structuredClone(answer);
assert.deepEqual(tex(renderToStaticMarkup(<AnsweredCard answer={answer} input={source} />)), Array(2).fill(String.raw`\nabla f`));
assert.deepEqual(answer, before);
});
function historical(): QuestionAnswerData {
return { kind: "quiz", questionText: brokenA, options: [{ index: 1, label: brokenB }, { index: 2, label: brokenA }],
answers: [{ index: 1, label: brokenB, value: "b" }], correct: false, correctIndices: [2], dontKnow: false,
explanation: brokenA, confidence: 70, answeredAt: "synthetic" };
}
test("historical source join restores matrices while retaining recorded order, answer and grade", () => {
const answer = historical();
const before = structuredClone({ answer, input });
const projection = answerForPresentation(answer, input);
assert.equal(projection.questionText, a);
assert.equal(projection.explanation, a);
assert.deepEqual(projection.options, [{ index: 1, label: b }, { index: 2, label: a }]);
assert.equal(projection.answers, answer.answers);
assert.equal(projection.correctIndices, answer.correctIndices);
assert.equal(projection.correct, false);
assert.equal(projection.confidence, 70);
const html = renderToStaticMarkup(<AnsweredCard answer={answer} input={input} />);
assert.deepEqual(tex(html), [a.slice(1,-1), b.slice(1,-1), a.slice(1,-1)]);
assert.match(html, /incorrect/);
assert.deepEqual({ answer, input }, before);
});
test("historical ambiguous options and absent/invalid original calls retain their snapshots", () => {
const source = { ...input, options: [...input.options, { label: String.raw`$\begin{matrix}x\\y\end{matrix}$`, value: "c" }] };
const answer = { ...historical(), answers: [], dontKnow: true, options: [
{ index: 1, label: brokenB }, { index: 2, label: brokenA }, { index: 3, label: String.raw`$\begin{matrix}x\y\end{matrix}$` },
] };
assert.deepEqual(answerForPresentation(answer, source).options, answer.options);
assert.equal(answerForPresentation(answer, undefined), answer);
assert.equal(answerForPresentation(answer, { question: a }), answer);
const dismissed = { kind: "quiz", dismissed: true } as QuestionAnswerData;
assert.equal(answerForPresentation(dismissed, input), dismissed);
const before = structuredClone(answer);
renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.deepEqual(answer, before);
});
+30 -32
View File
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { quizInputProblems } from "../src/core/question-core.ts";
import { Value } from "typebox/value";
import { restoreMathEscapes } from "../src/core/math-delims.ts";
import { repairMathForDisplay, restoreMathEscapes } from "../src/core/math-delims.ts";
import {
parseAskInput,
parseProbeDoneInput,
@@ -170,7 +170,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26
// The exact live shapes from the modal-logic session: single-backslash
// LaTeX inside JSON args — JSON escape resolution ate the command's
// first letter (\f → FF ate 'f' of \forall, \v → VT ate 'v' of \varphi).
it("parseQuizInput restores escape debris inside math spans (question + options + explanation)", () => {
it("presentation restores debris from parsed source fields (question + options + explanation)", () => {
const parsed = parseQuizInput({
question: "What does \\(\\forall x\\, \\varphi(x)\\) mean?",
options: [
@@ -182,18 +182,18 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26
});
assert.ok(parsed);
assert.equal(parsed.question, "What does \\(\\forall x\\, \\varphi(x)\\) mean?");
assert.equal(parsed.options[0]!.label, "\\(\\varphi\\) is true when every element satisfies it");
assert.equal(parsed.options[1]!.label, "\\(\\forall\\) and \\(\\exists\\) are just shorthand");
assert.equal(parsed.explanation, "\\(\\forall\\) quantifies over the domain.");
assert.equal(repairMathForDisplay(parsed.options[0]!.label), "\\(\\varphi\\) is true when every element satisfies it");
assert.equal(repairMathForDisplay(parsed.options[1]!.label), "\\(\\forall\\) and \\(\\exists\\) are just shorthand");
assert.equal(repairMathForDisplay(parsed.explanation), "\\(\\forall\\) quantifies over the domain.");
});
it("parseAskInput restores debris in ask option labels too", () => {
it("presentation restores debris from parsed ask option labels too", () => {
const parsed = parseAskInput({
question: "Goal?",
options: [{ label: "\\(\x0corall\\) rigor" }, { label: "Intuition first" }],
});
assert.ok(parsed);
assert.equal(parsed.options![0]!.label, "\\(\\forall\\) rigor");
assert.equal(repairMathForDisplay(parsed.options![0]!.label), "\\(\\forall\\) rigor");
});
it("restores TAB debris (the \\to case) inside spans only", () => {
@@ -204,7 +204,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26
explanation: "",
});
assert.ok(parsed);
assert.equal(parsed.question, "Is \\(p\\to q\\) valid?");
assert.equal(repairMathForDisplay(parsed.question), "Is \\(p\\to q\\) valid?");
});
it("control chars OUTSIDE math spans are left alone (real prose/pasted text)", () => {
@@ -253,7 +253,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
explanation: "",
});
assert.ok(parsed);
assert.equal(parsed.question, "Evaluate \\[\\left( a \\right)\\] carefully.");
assert.equal(repairMathForDisplay(parsed.question), "Evaluate \\[\\left( a \\right)\\] carefully.");
});
it("sloppy \\neq in display math stays broken — the accepted cost (newline kept, byte-identical to a real line break)", () => {
@@ -276,7 +276,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
explanation: "",
});
assert.ok(parsed);
assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?");
assert.equal(repairMathForDisplay(parsed.question), "Is \\(p \\neq q\\) valid?");
});
});
@@ -289,9 +289,9 @@ describe("dollar-delimiter recognition — existing escape restoration rules", (
explanation: "Here $p\to q$ is an implication.",
});
assert.ok(parsed);
assert.equal(parsed.question, "What does $\\forall x$ mean?");
assert.equal(parsed.options[0]!.label, "$\\varphi$ holds");
assert.equal(parsed.explanation, "Here $p\\to q$ is an implication.");
assert.equal(repairMathForDisplay(parsed.question), "What does $\\forall x$ mean?");
assert.equal(repairMathForDisplay(parsed.options[0]!.label), "$\\varphi$ holds");
assert.equal(repairMathForDisplay(parsed.explanation), "Here $p\\to q$ is an implication.");
});
it("keeps the same six-character inline recovery map", () => {
@@ -320,21 +320,21 @@ describe("dollar-delimiter recognition — existing escape restoration rules", (
}
});
it("preserves legacy replacement frequency when a span has multiple debris characters", () => {
it("restores all debris in one pass consistently across delimiter syntaxes", () => {
const body = "p\to q, \rho";
const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2);
assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`);
assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged");
assert.equal(legacy, "p\\to q, \\rho", "recovery converges instead of requiring a second render");
});
it("shares dollar recovery with ask and free-recall fields", () => {
const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] });
assert.ok(ask);
assert.equal(ask.options![0]!.label, "$\\forall$ rigor");
assert.equal(repairMathForDisplay(ask.options![0]!.label), "$\\forall$ rigor");
const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." });
assert.ok(explain);
assert.equal(explain.question, "Explain $p\\to q$");
assert.equal(explain.correctAnswer, "$p\\neq q$ is different.");
assert.equal(repairMathForDisplay(explain.question), "Explain $p\\to q$");
assert.equal(repairMathForDisplay(explain.correctAnswer), "$p\\neq q$ is different.");
});
it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => {
@@ -401,13 +401,9 @@ describe("parseExplainInput: the dock's display seam for explain calls", () => {
});
});
describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)", () => {
// xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash stochastically DOUBLE
// LaTeX command backslashes inside JSON tool arguments — per-call and
// per-field (gradient-descent msg 3: question doubled, explanation and
// options in the SAME call clean). restoreMathEscapesDeep now rides
// healOverescapedCommands after the debris restore, so doubled spans
// heal at every display parse: live dock, settled cards, old records.
describe("over-escaped command recovery after shape parsing", () => {
// Keep these real authoring shapes, but recover only for presentation.
// Parsed source is also used to build durable answers and identities.
const CLEAN =
"Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. Among all unit directions $\\|u\\|_2 = 1$, which makes $D_u f(x)$ most negative?";
const DOUBLE = (s: string): string => s.replace(/\\/g, "\\\\");
@@ -420,19 +416,21 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)"
explanation: "By Cauchy\u2013Schwarz: $$u^\\star = -\\frac{\\nabla f(x)}{\\|\\nabla f(x)\\|_2}$$",
};
it("parseQuizInput heals a doubled question field (the gradient-descent msg-3 shape)", () => {
it("presentation heals a doubled question field (the gradient-descent msg-3 shape)", () => {
const parsed = parseQuizInput({ ...BASE, question: DOUBLE(CLEAN) });
assert.ok(parsed, "parses");
assert.equal(parsed.question, CLEAN);
assert.equal(parsed.question, DOUBLE(CLEAN), "shape parsing retains source");
assert.equal(repairMathForDisplay(parsed.question), CLEAN);
// The CLEAN sibling fields of the same call stay byte-identical.
assert.equal(parsed.options[0]!.label, BASE.options[0]!.label);
assert.equal(parsed.explanation, BASE.explanation);
});
it("parseQuizInput heals a quadrupled field (the agent's over-correction, msg 6)", () => {
it("presentation heals a quadrupled field (the agent's over-correction, msg 6)", () => {
const parsed = parseQuizInput({ ...BASE, question: DOUBLE(DOUBLE(CLEAN)) });
assert.ok(parsed, "parses");
assert.equal(parsed.question, CLEAN);
assert.equal(parsed.question, DOUBLE(DOUBLE(CLEAN)), "shape parsing retains source");
assert.equal(repairMathForDisplay(parsed.question), CLEAN);
});
it("doubled option labels and explanations heal too (the inference-runtime msg-7 shape)", () => {
@@ -448,10 +446,10 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)"
assert.ok(parsed, "parses");
assert.equal(parsed.options[0]!.label, "$mkn$");
assert.equal(parsed.options[1]!.label, "$2mkn$");
assert.equal(parsed.explanation, "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$");
assert.equal(repairMathForDisplay(parsed.explanation), "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$");
});
it("parseAskInput heals doubled ask-option descriptions the same way", () => {
it("presentation heals doubled ask-option descriptions the same way", () => {
const parsed = parseAskInput({
question: "Depth?",
options: [
@@ -460,7 +458,7 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)"
],
});
assert.ok(parsed, "parses");
assert.equal(parsed.options![0]!.description, "why $-\\nabla f$ is steepest descent");
assert.equal(repairMathForDisplay(parsed.options![0]!.description!), "why $-\\nabla f$ is steepest descent");
});
it("clean records pass through byte-identical (no over-heal)", () => {
+2 -2
View File
@@ -17,7 +17,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Hint, hintText } from "../components/ui/hint.tsx";
import { TOOLTIP_DELAY_MS } from "../components/ui/tooltip.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/update-settings.tsx";
import { IS_MAC } from "../src/core/keyboard.ts";
describe("hintText — the parenthesized composition", () => {
@@ -46,7 +46,7 @@ describe("subtlety round pins (owner 2026-09-25)", () => {
describe("spot-pins: the dev-speak cull (owner copy review at merge)", () => {
it("the auto-update hint is honest user language — no stream jargon", () => {
assert.equal(AUTO_UPDATE_HINT, "Automatic updates arrive in an upcoming release");
assert.equal(AUTO_UPDATE_HINT, "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.");
assert.ok(!AUTO_UPDATE_HINT.includes("wave"), "no stream vocabulary");
});
});
+11
View File
@@ -11,8 +11,19 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
import { subscribe, type ServerEvent } from "../src/core/events.ts";
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
describe("turn liveness registry", () => {
it("retains a late-attach failure notice until the next accepted begin", async () => {
const id = "chat_failure_notice_unit";
rememberTurnFailure(id, "The reply could not be saved.");
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./);
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./, "reading does not consume the notice");
assert.equal(beginTurn(id), true);
assert.doesNotMatch(await settledTurnResponse(id).text(), /"type":"error"/);
endTurn(id);
});
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
const id = "chat_liveness_unit";
assert.equal(isTurnRunning(id), false, "initially idle");
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Agent, AgentMessage } from "@earendil-works/pi-agent-core";
import type { ChatStore } from "../src/core/ports.ts";
import { runPiTurn } from "../src/server/pi/ui-stream.ts";
import { persistTurn } from "../src/server/turn-service.ts";
/** No engine or network: exercise the runner's awaited listener seam. */
function agentEndingWith(loopFailure?: Error): Agent {
const listeners: Array<(event: never) => unknown> = [];
return {
state: { messages: [] },
subscribe(listener: (event: never) => unknown) { listeners.push(listener); return () => {}; },
async continue() {
await Promise.resolve(); // let the view subscribe before the terminal
if (loopFailure) throw loopFailure;
for (const listener of listeners) await listener({ type: "agent_end", messages: [] } as never);
},
abort() {},
} as unknown as Agent;
}
function wireFrames(wire: string): string[] {
return wire.split("\n\n").filter(Boolean).map(frame => frame.slice("data: ".length));
}
describe("turn settlement recovery", () => {
it("reports a failed canonical save before DONE and cleans up before reporting", async () => {
const order: string[] = [];
const handle = runPiTurn({
agent: agentEndingWith(),
onFinish() { order.push("save"); throw new Error("synthetic disk failure"); },
onSettleFailure() { order.push("cleanup"); },
onSettleError() { order.push("report"); return "The reply could not be saved."; },
});
const frames = wireFrames(await new Response(handle.subscribe()).text());
assert.deepEqual(order, ["save", "cleanup", "report"]);
assert.equal(frames.at(-1), "[DONE]");
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
assert.ok(frames.at(-2)?.includes("The reply could not be saved."));
});
it("still reports settlement failure when cleanup itself throws", async () => {
const handle = runPiTurn({
agent: agentEndingWith(),
onFinish() { throw new Error("synthetic save failure"); },
onSettleFailure() { throw new Error("synthetic cleanup failure"); },
onSettleError: () => "The reply could not be saved.",
});
const frames = wireFrames(await new Response(handle.subscribe()).text());
assert.equal(frames.at(-1), "[DONE]");
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
});
it("does not replace an already reported loop failure with a second settlement error", async () => {
let cleanups = 0;
const handle = runPiTurn({
agent: agentEndingWith(new Error("synthetic loop failure")),
onError: () => "An error occurred.",
onFinish() { throw new Error("synthetic save failure"); },
onSettleFailure(error, reportedErrorText) {
cleanups++;
assert.match(String(error), /synthetic save failure/);
assert.equal(reportedErrorText, "An error occurred.");
},
});
const frames = wireFrames(await new Response(handle.subscribe()).text());
assert.equal(cleanups, 1);
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
assert.equal(frames.at(-1), "[DONE]");
});
it("a healthy terminal follows persistence and emits no error", async () => {
let saves = 0;
const handle = runPiTurn({ agent: agentEndingWith(), onFinish() { saves++; } });
const frames = wireFrames(await new Response(handle.subscribe()).text());
assert.equal(saves, 1);
assert.equal(frames.at(-1), "[DONE]");
assert.equal(frames.some(f => f.includes('"type":"error"')), false);
});
it("does not contain a canonical write failure as an empty successful persist", () => {
const store = {
getChat: () => ({ messages: [] }),
saveMessages() { throw new Error("synthetic disk failure"); },
} as unknown as ChatStore;
const message = { role: "assistant", content: [{ type: "text", text: "Synthetic reply" }], stopReason: "stop", timestamp: 1 } as AgentMessage;
assert.throws(() => persistTurn(store, "fixture", 0, [message], false), /The reply could not be saved\./);
});
});
+99
View File
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import test, { afterEach } from "node:test";
import { parseHTML } from "linkedom";
import { createNotifyHub } from "../components/server-events.tsx";
import { createWebChatGPTBridge, createWebNativeBridge } from "../components/settings/subscription-client.ts";
import { createRequire } from "node:module";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document });
const originalFetch = globalThis.fetch;
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
afterEach(() => { globalThis.fetch = originalFetch; delete window.lh; });
test("web ChatGPT reserves a new tab before async login, navigates only to an opaque action and receives push/resync", async () => {
const calls: string[] = [];
const tab = { opener: {}, closed: false, location: { href: "about:blank" }, close() { this.closed = true; } };
window.open = (() => { calls.push("tab"); return tab; }) as unknown as typeof window.open;
const safe = { available: true, pending: true, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
globalThis.fetch = async (url, options) => {
assert.equal(url, "/api/subscriptions"); assert.equal(new Headers(options?.headers).get("x-learnharness-subscriptions"), "1");
if (!options?.method) return Response.json({ chatgpt: safe, native: [] });
calls.push("login"); assert.equal(tab.opener, null);
assert.deepEqual(JSON.parse(String(options.body)), { providerId: "chatgpt", op: "login" });
return Response.json({ value: safe, browserAction: action });
};
const hub = createNotifyHub(), bridge = createWebChatGPTBridge(hub);
assert.deepEqual(await bridge.login(), safe); assert.deepEqual(calls, ["tab", "login"]);
assert.equal(tab.location.href, action); assert(!tab.closed);
let updates = 0; const off = bridge.onChange(() => updates++);
hub.dispatch("subscriptions-changed", "{}"); hub.fireOpen(); assert.equal(updates, 2);
off(); hub.fireOpen(); assert.equal(updates, 2);
assert.deepEqual(await bridge.status(), safe);
});
test("blocked popups do not start OAuth; failed login closes the reserved tab", async () => {
const bridge = createWebChatGPTBridge(null);
window.open = () => null;
globalThis.fetch = async () => assert.fail("blocked popup must not start sign-in");
await assert.rejects(bridge.login(), /Allow a sign-in tab/);
const tab = { opener: {}, closed: false, close() { this.closed = true; } };
window.open = (() => tab) as unknown as typeof window.open;
globalThis.fetch = async () => new Response(null, { status: 400 });
await assert.rejects(bridge.login()); assert(tab.closed);
});
test("native web login retains device prompts; only the explicit Open browser action opens a tab", async () => {
const requests: Record<string, unknown>[] = []; let tabs = 0;
const tab = { opener: {}, closed: false, location: { href: "" }, close() {} };
window.open = (() => { tabs++; return tab; }) as unknown as typeof window.open;
globalThis.fetch = async (_url, options) => {
const command = JSON.parse(String(options?.body)); requests.push(command);
return Response.json(command.op === "open" ? { browserAction: action } : {});
};
const bridge = createWebNativeBridge(null);
await bridge.login("github-copilot", false); assert.equal(tabs, 0);
await bridge.respond("github-copilot", "attempt", "prompt", "");
await bridge.open("github-copilot", "attempt", "action"); assert.equal(tabs, 1); assert.equal(tab.location.href, action);
assert.deepEqual(requests, [{ providerId: "github-copilot", op: "login", enabled: false }, { providerId: "github-copilot", op: "respond", attemptId: "attempt", promptId: "prompt", value: "" }, { providerId: "github-copilot", op: "open", attemptId: "attempt", actionId: "action" }]);
});
test("Electron dev uses its narrow external-browser bridge, without attempting a renderer popup", async () => {
const opened: string[] = [];
window.lh = { expandWindow() {}, subscriptionTransport: "web", openSubscriptionBrowser: async (target) => { opened.push(target); } };
window.open = () => assert.fail("Electron denies renderer popups");
globalThis.fetch = async () => Response.json({ value: { pending: true }, browserAction: action });
await createWebChatGPTBridge(null).login(); assert.deepEqual(opened, [action]);
});
test("web notices identify the server machine and same-machine ChatGPT sign-in; desktop copy stays unchanged", async () => {
const { CHATGPT_NOTICE, chatGPTNotice } = await import("../components/settings/chatgpt-connection.tsx");
const { subscriptionNotice, subscriptionDisconnectCopy } = await import("../components/settings/subscription-connections.tsx");
assert.equal(chatGPTNotice(), CHATGPT_NOTICE); assert(CHATGPT_NOTICE.includes("on this device"));
assert(chatGPTNotice(true).includes("on the machine running LearnHarness"));
assert(chatGPTNotice(true).endsWith("Complete ChatGPT sign-in in a browser on that same machine."));
for (const label of ["GitHub Copilot", "Grok"]) {
assert(subscriptionNotice(label).includes("stored locally")); assert(subscriptionDisconnectCopy(label).includes("from this device"));
assert(subscriptionNotice(label, true).includes("on the machine running LearnHarness"));
assert(subscriptionDisconnectCopy(label, true).includes("from the machine running LearnHarness"));
}
});
const require = createRequire(import.meta.url);
const { openWebSubscriptionBrowser } = require("../electron/web-subscription-browser.cjs");
test("desktop-dev opener accepts only local action IDs, does not follow redirects or use browser cookies", async () => {
const opened: string[] = [];
const options = {
openExternal: async (url: string) => { opened.push(url); },
fetch: async (url: URL, init: RequestInit) => {
assert.equal(url.origin, "http://localhost:3000"); assert.equal(url.pathname, "/api/subscriptions/browser");
assert.equal(init.method, "POST"); assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit");
assert.equal(new Headers(init.headers).get("origin"), "http://localhost:3000");
return new Response(null, { status: 303, headers: { Location: "https://auth.openai.com/authorize?state=fixture" } });
},
};
await openWebSubscriptionBrowser(action, "http://localhost:3000", options); assert.equal(opened.length, 1);
for (const target of ["https://unrelated.invalid", "//unrelated.invalid", "/api/keys", `${action}&target=evil`]) await assert.rejects(openWebSubscriptionBrowser(target, "http://localhost:3000", options), /Invalid sign-in action/);
for (const target of ["file:///private/fixture", "http://unrelated.invalid", "https://user:pass@unrelated.invalid"]) await assert.rejects(openWebSubscriptionBrowser(action, "http://localhost:3000", { ...options, fetch: async () => new Response(null, { status: 303, headers: { Location: target } }) }));
assert.equal(opened.length, 1);
});
+21
View File
@@ -0,0 +1,21 @@
/** Exercise the real curator against synthetic payloads, never the repo payload. */
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
for (const name of ["chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json"]) {
for (const secret of [name, `${name}.fixture.tmp`]) test(`payload curator refuses web credentials: ${secret}`, () => {
const root = mkdtempSync(path.join(tmpdir(), "lh-sub-payload-"));
try {
mkdirSync(path.join(root, "scripts")); mkdirSync(path.join(root, ".next", "standalone"), { recursive: true }); mkdirSync(path.join(root, ".next", "static"));
copyFileSync(new URL("../scripts/build-server.mjs", import.meta.url), path.join(root, "scripts", "build-server.mjs"));
writeFileSync(path.join(root, ".next", "standalone", "server.js"), "// fixture");
writeFileSync(path.join(root, ".next", "standalone", secret), "fixture-not-real-credentials", { mode: 0o600 });
const result = spawnSync(process.execPath, [path.join(root, "scripts", "build-server.mjs")], { encoding: "utf8", env: process.env });
assert.equal(result.status, 1); assert(result.stderr.includes(`REFUSING: standalone contains ${secret}`));
} finally { rmSync(root, { recursive: true, force: true }); }
});
}
+140
View File
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createRequire } from "node:module";
import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai";
import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot";
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-sub-"));
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
process.env.LH_DATA_DIR = path.join(directory, "data");
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
await fs.writeFile(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
const require = createRequire(import.meta.url);
const { SCOPES } = require("../electron/chatgpt.cjs");
const { createWebSubscriptionOwner, webSubscriptionOwner, webSubscriptionsEnabled } = await import("../src/server/web-subscriptions.ts");
const { subscriptionControlsResponse, subscriptionBrowserResponse } = await import("../src/server/web-subscription-http.ts");
const chat = await import("../src/agent/chatgpt-bridge.ts");
const native = await import("../src/agent/subscription-bridge.ts");
const { piModels } = await import("../src/agent/models.ts");
const { refreshConnectionCatalog } = await import("../src/server/model-catalog.ts");
const model = piModels().getModels("openai").find((entry) => entry.api === "openai-responses")!;
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
assert.equal(String(input), "https://api.openai.com/v1/models", "unexpected network request forbidden");
return Response.json({ models: [{ slug: model.id, display_name: "Fixture ChatGPT", visibility: "list" }] });
};
const copilot = githubCopilotProvider().getModels()[0]!;
const credentials = (id: string): OAuthCredential => ({ type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) });
await fs.writeFile(path.join(directory, "chatgpt-credentials.json"), JSON.stringify({ version: 1, hostId: "fixture-host", allowBackgroundUse: true, activeId: "fixture-account", registrations: [{ id: "fixture-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }] }), { mode: 0o600 });
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(directory, `${id}-credentials.json`), JSON.stringify({ version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001", credential: credentials(id) }), { mode: 0o600 });
function request(command?: Record<string, unknown>, origin = "http://192.168.1.10:3000") {
return new Request(`${origin}/api/subscriptions`, { method: command ? "POST" : "GET", headers: { host: new URL(origin).host, origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" }, ...(command ? { body: JSON.stringify(command) } : {}) });
}
after(async () => { webSubscriptionOwner().close(); globalThis.fetch = originalFetch; await fs.rm(directory, { recursive: true, force: true }); });
test("ordinary web restores configured sibling credentials, private runtime leases work for LAN-owned requests", async () => {
await refreshConnectionCatalog();
assert(chat.chatGPTConnected()); assert(native.nativeSubscriptionConnected("github-copilot")); assert(native.nativeSubscriptionConnected("xai"));
assert.equal((await chat.chatGPTLease(chat.chatGPTScope(model.id), false)).accessToken, "fixture-chatgpt-access");
const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!;
assert.equal((await native.subscriptionLease(native.subscriptionScope("xai", xaiModel.id), false)).apiKey, "fixture-xai-access");
const response = await subscriptionControlsResponse(request());
assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store");
const publicState = await response.text();
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!publicState.includes(secret));
assert(publicState.includes("fixture@example.invalid"));
});
test("web background OFF immediately aborts background streams, retains foreground and rejects private background leases", async () => {
const foreground = chat.chatGPTAbortController(false), background = chat.chatGPTAbortController(true);
const nativeForeground = native.subscriptionAbortController("xai", false), nativeBackground = native.subscriptionAbortController("xai", true);
try {
assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op: "consent", enabled: false }))).status, 200);
assert(background.controller.signal.aborted); assert(!foreground.controller.signal.aborted);
const scope = chat.chatGPTScope(model.id); await chat.chatGPTLease(scope, false); await assert.rejects(chat.chatGPTLease(scope, true));
assert.equal((await subscriptionControlsResponse(request({ providerId: "xai", op: "consent", enabled: false }))).status, 200);
assert(nativeBackground.controller.signal.aborted); assert(!nativeForeground.controller.signal.aborted);
const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!;
const nativeScope = native.subscriptionScope("xai", xaiModel.id); await native.subscriptionLease(nativeScope, false); await assert.rejects(native.subscriptionLease(nativeScope, true));
await subscriptionControlsResponse(request({ providerId: "xai", op: "disconnect" }));
assert(nativeForeground.controller.signal.aborted); assert(!native.nativeSubscriptionConnected("xai"));
assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: false });
} finally { foreground.release(); background.release(); nativeForeground.release(); nativeBackground.release(); }
});
test("HTTP controls reject cross-origin, simple-form and private credential operations; desktop never falls back", async () => {
const forged = request({ providerId: "chatgpt", op: "consent", enabled: true }); forged.headers.set("origin", "https://unrelated.invalid");
assert.equal((await subscriptionControlsResponse(forged)).status, 403);
const noHeader = request(); noHeader.headers.delete("x-learnharness-subscriptions");
assert.equal((await subscriptionControlsResponse(noHeader)).status, 403);
const noOrigin = request({ providerId: "chatgpt", op: "cancel" }); noOrigin.headers.delete("origin");
assert.equal((await subscriptionControlsResponse(noOrigin)).status, 403);
for (const op of ["lease", "models", "snapshot", "token", "openExternal"]) assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op }))).status, 400);
const huge = request({ providerId: "chatgpt", op: "cancel", padding: "x".repeat(16384) });
assert.equal((await subscriptionControlsResponse(huge)).status, 413);
const browser = new Request("http://localhost:3000/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001");
assert.equal((await subscriptionBrowserResponse(browser)).status, 403);
for (const [marker, secret] of [["1", undefined], [undefined, "fixture-capability"]]) {
if (marker) process.env.LH_DESKTOP_SERVER = marker; else delete process.env.LH_DESKTOP_SERVER;
if (secret) process.env.LH_DESKTOP_API_SECRET = secret; else delete process.env.LH_DESKTOP_API_SECRET;
assert(!webSubscriptionsEnabled());
assert.equal((await subscriptionControlsResponse(request())).status, 404);
await assert.rejects(chat.chatGPTRequest("status"), /desktop app/);
}
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
});
function fakeSDK(login: (id: string, interaction: ProviderAuthInteraction) => Promise<OAuthCredential>) {
const factory = (id: string) => {
const provider = id === "xai" ? xaiProvider() : githubCopilotProvider();
return { ...provider, auth: { oauth: { ...provider.auth.oauth!, login: (interaction: ProviderAuthInteraction) => login(id, interaction), refresh: async () => credentials(id) } } };
};
return { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") };
}
test("device browser actions preserve complete URLs privately, are origin-bound, one-use and cancellation-bound", async () => {
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-action-"));
let complete!: (credential: OAuthCredential) => void;
const pending = new Promise<OAuthCredential>((resolve) => { complete = resolve; });
let notified!: () => void;
const notification = new Promise<void>((resolve) => { notified = resolve; });
const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC" }); notified(); return pending; }) });
try {
const login = owner.control("xai", { op: "login" }, "http://localhost:3000"); await notification;
const interaction = (await owner.native.status()).find((entry: { providerId: string }) => entry.providerId === "xai")!.interaction;
const command = { op: "open", attemptId: interaction.attemptId, actionId: interaction.browser.id };
const opened = await owner.control("xai", command, "http://localhost:3000");
assert(opened.browserAction); assert(!JSON.stringify(opened).includes("auth.x.ai"));
const id = new URL(opened.browserAction, "http://localhost:3000").searchParams.get("id")!;
assert.equal(await owner.takeBrowserAction(id, "http://other.invalid"), undefined);
assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), "https://auth.x.ai/device?user_code=PUBLIC");
assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), undefined);
const stale = await owner.control("xai", command, "http://localhost:3000");
await owner.control("xai", { op: "cancel" }, "http://localhost:3000");
complete(credentials("xai")); await login;
assert.equal(await owner.takeBrowserAction(new URL(stale.browserAction!, "http://localhost:3000").searchParams.get("id")!, "http://localhost:3000"), undefined);
} finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); }
});
test("web ChatGPT login supplies only an opaque browser action; cancel/replacement invalidates earlier launch", async () => {
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-chat-action-"));
const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (id) => credentials(id)) });
try {
const first = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000");
assert(first.browserAction); assert(!JSON.stringify(first).includes("auth.openai.com"));
await owner.control("chatgpt", { op: "cancel" }, "http://localhost:3000");
const second = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000");
assert(second.browserAction);
const firstId = new URL(first.browserAction, "http://localhost:3000").searchParams.get("id")!;
assert.equal(await owner.takeBrowserAction(firstId, "http://localhost:3000"), undefined);
const secondId = new URL(second.browserAction, "http://localhost:3000").searchParams.get("id")!;
const target = new URL((await owner.takeBrowserAction(secondId, "http://localhost:3000"))!);
assert.equal(target.origin, "https://auth.openai.com");
const callback = new URL(target.searchParams.get("redirect_uri")!);
assert.equal(callback.hostname, "127.0.0.1"); assert.equal(callback.pathname, "/auth/callback");
assert(target.searchParams.has("state")); assert(target.searchParams.has("code_challenge"));
} finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); }
});