Merge accepted local main into PR #184 working branch
This commit is contained in:
@@ -123,9 +123,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 +158,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");
|
||||
});
|
||||
|
||||
@@ -27,12 +27,14 @@ const {
|
||||
ensureCompacted,
|
||||
kickCompaction,
|
||||
awaitCompaction,
|
||||
compactionInFlight,
|
||||
effectiveThresholdTokens,
|
||||
projectedContextTokens,
|
||||
} = await import("../src/server/compaction.ts");
|
||||
import type { CompactionDeps } from "../src/server/compaction.ts";
|
||||
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
const { subscribe } = await import("../src/core/events.ts");
|
||||
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
||||
const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
||||
import type { LhMessage } from "../src/core/index.ts";
|
||||
|
||||
after(() => {
|
||||
@@ -167,12 +169,120 @@ describe("the compaction service", () => {
|
||||
await ensureCompacted(deps, chatId);
|
||||
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
|
||||
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
|
||||
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once");
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
|
||||
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
|
||||
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
|
||||
});
|
||||
|
||||
it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => {
|
||||
const chat = chatStore.createChat("repeated-cycle fixture");
|
||||
chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8)));
|
||||
let calls = 0;
|
||||
const deps: CompactionDeps = {
|
||||
...depsOf(chat.id, 150_000),
|
||||
summarize: async (prompt) => {
|
||||
assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1);
|
||||
if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward");
|
||||
return {
|
||||
summary: `SUMMARY-${++calls}.`,
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
};
|
||||
},
|
||||
};
|
||||
for (let cycle = 1; cycle <= 10; cycle++) {
|
||||
const messages = [...chatStore.getChat(chat.id)!.messages];
|
||||
if (cycle > 1) messages.push(big(`growth-${cycle}`, 8));
|
||||
// Controlled provider readings trigger compaction while the visible
|
||||
// retained tail advances slowly enough to contain older markers.
|
||||
messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage);
|
||||
chatStore.saveMessages(chat.id, messages);
|
||||
await ensureCompacted(deps, chat.id);
|
||||
assert.equal(calls, cycle);
|
||||
const record = chatStore.getChat(chat.id)!;
|
||||
assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored");
|
||||
assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle);
|
||||
const wire = JSON.stringify(toLlmMessages(record.messages));
|
||||
assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`);
|
||||
assert.ok(wire.includes(`SUMMARY-${cycle}.`));
|
||||
assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings");
|
||||
}
|
||||
});
|
||||
|
||||
it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => {
|
||||
const chat = chatStore.createChat("summary span fixture");
|
||||
const kept = big("previously retained");
|
||||
const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" };
|
||||
const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 };
|
||||
const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")];
|
||||
chatStore.saveMessages(chat.id, messages);
|
||||
let seenPrompt = "";
|
||||
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
|
||||
seenPrompt = prompt;
|
||||
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
|
||||
} }, chat.id);
|
||||
assert.ok(seenPrompt.includes("previously retained"));
|
||||
assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span");
|
||||
assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY"));
|
||||
assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1);
|
||||
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
|
||||
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
|
||||
});
|
||||
|
||||
it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => {
|
||||
const chat = chatStore.createChat("legacy summary fixture");
|
||||
const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" });
|
||||
const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")];
|
||||
chatStore.saveMessages(chat.id, messages);
|
||||
let seenPrompt = "";
|
||||
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
|
||||
seenPrompt = prompt;
|
||||
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
|
||||
} }, chat.id);
|
||||
assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1);
|
||||
assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff");
|
||||
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
|
||||
});
|
||||
|
||||
it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => {
|
||||
const chatId = overThresholdChat("incomplete summary fixture");
|
||||
await ensureCompacted(depsOf(chatId, 40_000), chatId);
|
||||
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]);
|
||||
const faux = createFauxCore({});
|
||||
const { summarize: _injected, ...base } = depsOf(chatId, 40_000);
|
||||
const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } };
|
||||
const before = chatStore.getChat(chatId)!;
|
||||
assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too");
|
||||
const events: string[] = [];
|
||||
const off = subscribe((event) => {
|
||||
const { chatId: id, running } = event as { chatId?: string; running?: boolean };
|
||||
if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`);
|
||||
});
|
||||
// Exercise the actual zero-tool summarizer with an offline provider;
|
||||
// NODE_ENV=test otherwise deliberately skips non-injected summaries.
|
||||
const env: Record<string, string | undefined> = process.env;
|
||||
const originalNodeEnv = env.NODE_ENV;
|
||||
env.NODE_ENV = "development";
|
||||
try {
|
||||
faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]);
|
||||
await ensureCompacted(deps, chatId);
|
||||
assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint");
|
||||
assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate");
|
||||
assert.deepEqual(events, ["true", "false"]);
|
||||
faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]);
|
||||
await ensureCompacted(deps, chatId);
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY"));
|
||||
assert.deepEqual(record.messages.slice(0, -1), before.messages);
|
||||
assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY"));
|
||||
} finally {
|
||||
if (originalNodeEnv === undefined) delete env.NODE_ENV;
|
||||
else env.NODE_ENV = originalNodeEnv;
|
||||
off();
|
||||
}
|
||||
});
|
||||
|
||||
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
|
||||
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
|
||||
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
|
||||
|
||||
@@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => {
|
||||
assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once");
|
||||
});
|
||||
|
||||
it("omits nine superseded summaries inside the retained tail without changing stored history", () => {
|
||||
const kept = newUserMessage("retained exchange");
|
||||
const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" };
|
||||
const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details));
|
||||
const fresh = newCompactionMarker("LATEST SUMMARY", details);
|
||||
const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh];
|
||||
const original = JSON.stringify(messages);
|
||||
const out = toLlmMessages(messages);
|
||||
const flat = JSON.stringify(out);
|
||||
assert.equal(out.length, 3, "latest summary + retained exchange + latest reply");
|
||||
assert.ok(flat.includes("LATEST SUMMARY"));
|
||||
assert.ok(!flat.includes("SUPERSEDED-"));
|
||||
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
|
||||
assert.equal(JSON.stringify(messages), original, "projection never rewrites the record");
|
||||
});
|
||||
|
||||
it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => {
|
||||
const kept = [assistantWithCall("a1", "call_1")];
|
||||
const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" });
|
||||
@@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => {
|
||||
assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)");
|
||||
});
|
||||
|
||||
it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => {
|
||||
const kept = newUserMessage("retained exchange");
|
||||
const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" });
|
||||
const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" });
|
||||
const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh];
|
||||
const original = JSON.stringify(messages);
|
||||
const out = toLlmMessages(messages);
|
||||
assert.equal(out.length, messages.length);
|
||||
assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff");
|
||||
const flat = JSON.stringify(out);
|
||||
assert.ok(flat.includes("OLD SUMMARY"));
|
||||
assert.ok(flat.includes("LATEST SUMMARY"));
|
||||
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2);
|
||||
assert.equal(JSON.stringify(messages), original);
|
||||
});
|
||||
|
||||
it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => {
|
||||
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
|
||||
const m1 = newUserMessage("x");
|
||||
@@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
|
||||
assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize");
|
||||
});
|
||||
|
||||
it("preserves legacy summary text when the caller has no separately supplied prior summary", () => {
|
||||
const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" });
|
||||
assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY"));
|
||||
});
|
||||
|
||||
it("the prompt demands the four sections in order and carries the plan tree verbatim", () => {
|
||||
const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" });
|
||||
const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map(
|
||||
@@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
|
||||
const record = (): LhMessage[] => [
|
||||
{ id: "old", role: "user", content: "dropped history", timestamp: 1 },
|
||||
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 },
|
||||
newCompactionMarker("SUPERSEDED SUMMARY", {
|
||||
firstKeptMessageId: "kept",
|
||||
tokensBefore: 98,
|
||||
at: new Date(0).toISOString(),
|
||||
}),
|
||||
newCompactionMarker("SUMMARY", {
|
||||
firstKeptMessageId: "kept",
|
||||
tokensBefore: 99,
|
||||
@@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
|
||||
: ((m.content as { text?: string }[])[0]?.text ?? "");
|
||||
assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail"));
|
||||
assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history"));
|
||||
assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY"));
|
||||
assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1);
|
||||
});
|
||||
|
||||
it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => {
|
||||
|
||||
@@ -0,0 +1,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,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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user