Compare commits

...
15 Commits
35 changed files with 2264 additions and 516 deletions
+16 -6
View File
@@ -13,7 +13,7 @@
* healthy. The dock's chips + graph refetch track progress live through
* /api/graph/status, as with session runs.
*/
import { extraction } from "@/src/server";
import { extraction, NotesImportError } from "@/src/server";
import {
ModelsNotConfiguredError,
PROVIDER_KEY_MISSING_CODE,
@@ -24,6 +24,8 @@ import {
export const dynamic = "force-dynamic";
interface ImportResult {
requestIndex: number;
outcome: "completed" | "failed" | "partial" | "aborted" | "busy";
filePath: string;
ok: boolean;
stats?: EmissionStats;
@@ -46,20 +48,22 @@ export async function POST(request: Request) {
}
const results: ImportResult[] = [];
const queue = [...files];
const queue = files.map((file, requestIndex) => ({ file, requestIndex }));
const workers = Array.from({ length: Math.min(3, files.length) }, async () => {
for (;;) {
const file = queue.shift();
if (!file) return;
const next = queue.shift();
if (!next) return;
const { file, requestIndex } = next;
try {
const text = await file.text();
const stats = await extraction.importNotes({ filePath: file.name, text });
results.push({ filePath: file.name, ok: true, stats });
results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats });
} catch (err) {
if (err instanceof ModelsNotConfiguredError) {
results.push({
filePath: file.name,
ok: false,
requestIndex, outcome: "failed",
error: err.envelope.error,
code: "models_not_configured",
missing: err.envelope.missing,
@@ -70,6 +74,7 @@ export async function POST(request: Request) {
results.push({
filePath: file.name,
ok: false,
requestIndex, outcome: "failed",
error: err.envelope.error,
code: PROVIDER_KEY_MISSING_CODE,
provider: err.envelope.provider,
@@ -77,7 +82,12 @@ export async function POST(request: Request) {
});
continue;
}
results.push({ filePath: file.name, ok: false, error: err instanceof Error ? err.message : String(err) });
results.push({
filePath: file.name, requestIndex, ok: false,
outcome: err instanceof NotesImportError ? err.outcome : "failed",
...(err instanceof NotesImportError ? { stats: err.partialStats } : {}),
error: err instanceof Error ? err.message : String(err),
});
}
}
});
+9 -17
View File
@@ -64,6 +64,7 @@
*/
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
import Link from "next/link";
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
@@ -1088,29 +1089,19 @@ export function GraphView({
// /api/graph/import; the runs surface in the status chips below, and the
// graph refetches when they finish (the runs-empty transition).
const fileInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
// The dock's start-site nudge (accuracy wave, 2026-09-19): the import
// POST awaits the WHOLE pipeline server-side, so the run registers long
// before the response — nudge at click and again at +600ms to catch the
// start; from its first setPhase the run announces itself on the events
// channel (S5), and the dock rides those announcements thereafter.
const refreshActivity = useActivity().refresh;
const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
setImporting(true);
refreshActivity();
setTimeout(() => refreshActivity(), 600);
const form = new FormData();
for (const file of Array.from(files)) form.append("files", file);
fetch("/api/graph/import", { method: "POST", body: form })
.then(async (res) => {
const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] };
const failed = body.results.filter((r) => !r.ok);
if (failed.length > 0) console.error("notes import failed for:", failed);
})
.catch((err) => console.error("import failed:", err))
.finally(() => setImporting(false));
};
const notesImport = useNotesImport({
onStart: () => {
refreshActivity();
setTimeout(() => refreshActivity(), 600);
},
});
const { importing, onImportFiles } = notesImport;
// Background pipeline status: read /api/graph/status; whenever a run
// LEAVES the feed (finished / aborted / failed), refetch the graph.
@@ -1614,6 +1605,7 @@ export function GraphView({
/>
</div>
<NotesImportErrors errors={notesImport.errors} />
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
just a number, hover-highlighted, dropdown breakdown — then
the running-pipeline pills under it. Lives BELOW the actions
+5 -20
View File
@@ -22,6 +22,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ListTreeIcon, UploadIcon } from "lucide-react";
import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { GraphCostControl } from "@/components/graph-view.tsx";
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
import { Hint } from "@/components/ui/hint";
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
@@ -96,7 +97,6 @@ export function MinimalHome({
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [chainsOpen, setChainsOpen] = useState(false);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const readState = useCallback(() => {
@@ -123,25 +123,9 @@ export function MinimalHome({
if (chainSelectOpen) setChainsOpen(true);
}, [chainSelectOpen]);
// Notes import — the same front door as the graph home: POST awaits
// the whole pipeline server-side, then one refetch lands the numbers.
const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
setImporting(true);
const form = new FormData();
for (const file of Array.from(files)) form.append("files", file);
fetch("/api/graph/import", { method: "POST", body: form })
.then(async (res) => {
const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] };
const failed = body.results.filter((r) => !r.ok);
if (failed.length > 0) console.error("notes import failed for:", failed);
})
.catch((err) => console.error("import failed:", err))
.finally(() => {
setImporting(false);
readState();
});
};
// Refetch even after incomplete imports: earlier writes and spend remain.
const notesImport = useNotesImport({ onFinish: readState });
const { importing, onImportFiles } = notesImport;
const teachSessions = chats.filter((c) => c.kind === "teach").length;
const practiceSessions = chats.filter((c) => c.kind === "practice").length;
@@ -210,6 +194,7 @@ export function MinimalHome({
</Hint>
</div>
</div>
<NotesImportErrors errors={notesImport.errors} />
{costs && costs.runs.length > 0 && <GraphCostControl costs={costs} />}
</div>
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useRef, useState } from "react";
// Transport-only types: never import the server's extraction/agent module here.
export interface NotesImportResult {
requestIndex: number;
filePath: string;
ok: boolean;
outcome: "completed" | "failed" | "partial" | "aborted" | "busy";
stats?: { nodes: number; chains?: number; droppedChains?: number };
error?: string;
code?: string;
missing?: string[];
provider?: string;
slots?: string[];
}
/** Validate the entire response before applying any row: a malformed index
* must never attribute another file's success to this file (same names included). */
export function parseNotesImportResults(body: unknown, files: readonly File[]): NotesImportResult[] {
const invalid = () => new Error("Invalid import response. Please retry the file.");
if (!body || typeof body !== "object" || !("results" in body) || !Array.isArray(body.results) || body.results.length !== files.length) throw invalid();
const seen = new Set<number>();
for (const raw of body.results) {
if (!raw || typeof raw !== "object") throw invalid();
const r = raw as NotesImportResult;
if (!Number.isInteger(r.requestIndex) || r.requestIndex < 0 || r.requestIndex >= files.length || seen.has(r.requestIndex) || r.filePath !== files[r.requestIndex]!.name) throw invalid();
seen.add(r.requestIndex);
if (!["completed", "failed", "partial", "aborted", "busy"].includes(r.outcome) || typeof r.ok !== "boolean" || r.ok !== (r.outcome === "completed")) throw invalid();
if (r.stats !== undefined && (!r.stats || typeof r.stats !== "object" || !Number.isFinite(r.stats.nodes) || r.stats.nodes < 0 || (r.stats.chains !== undefined && (!Number.isFinite(r.stats.chains) || r.stats.chains < 0)))) throw invalid();
if (r.ok && !r.stats) throw invalid();
if (!r.ok && typeof r.error !== "string") throw invalid();
for (const field of ["error", "code", "provider"] as const) if (r[field] !== undefined && typeof r[field] !== "string") throw invalid();
for (const field of ["missing", "slots"] as const) if (r[field] !== undefined && (!Array.isArray(r[field]) || !r[field]!.every((v) => typeof v === "string"))) throw invalid();
}
return body.results as NotesImportResult[];
}
export async function postNotesImport(files: readonly File[]): Promise<NotesImportResult[]> {
const form = new FormData();
for (const file of files) form.append("files", file);
const response = await fetch("/api/graph/import", { method: "POST", body: form });
let body: unknown;
try { body = await response.json(); }
catch { throw new Error("Invalid import response. Please retry the file."); }
if (!response.ok) {
const detail = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? `: ${body.error}` : "";
throw new Error(`Import request failed (HTTP ${response.status})${detail}. Please retry the file.`);
}
return parseNotesImportResults(body, files);
}
/** Only error text survives a request; selected Files stay request-local. */
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
const [errors, setErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
const pending = useRef(false);
const submit = async (files: File[]) => {
if (pending.current || files.length === 0) return;
pending.current = true;
setImporting(true);
setErrors([]);
try {
callbacks.onStart?.();
const results = await postNotesImport(files);
setErrors(results
.filter((result) => !result.ok)
.sort((a, b) => a.requestIndex - b.requestIndex)
.map((r) => `${r.filePath}: ${r.error}${r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : ""}`));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrors(files.map((file) => `${file.name}: ${message}`));
} finally {
pending.current = false;
setImporting(false);
callbacks.onFinish?.();
}
};
return {
errors, importing,
onImportFiles: (files: FileList | null) => { if (files) void submit(Array.from(files)); },
};
}
export function NotesImportErrors({ errors }: { errors: readonly string[] }) {
return <>{errors.map((error, i) => (
<p key={i} role="alert" className="max-w-full whitespace-pre-wrap break-words text-xs text-red-400">{error}</p>
))}</>;
}
+4 -4
View File
@@ -2,7 +2,7 @@
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import type { ArtifactPartProps } from "@/src/core";
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
/**
* The practice transcript cards (practice sessions, owner design
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{statement}</MessageResponse>
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
</div>
</div>
);
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
(owner round 3): the same quiet 50% register as quiz explanations. */}
{verdict.length > 0 && (
<div className="text-sm text-foreground/50">
<MessageResponse>{verdict}</MessageResponse>
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
</div>
)}
{/* Then the divider, then the labeled model solution (Slice G: the
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{reveal}</MessageResponse>
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
</div>
</div>
</>
+2 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { cn } from "@/lib/utils";
import { repairMathForDisplay } from "@/src/core";
/**
* The practice dock (practice sessions, owner design 2026-10-04): an
@@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{input.statement}</MessageResponse>
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
</div>
</div>
+2 -2
View File
@@ -213,7 +213,7 @@ export function SidebarToggle() {
* phases only advance (all sessions end in Teaching — phase-as-signal was
* decoration, now retired app-wide), and question-pending is the dominant
* RESTING state of real sessions — a resting state has no scan value, so
* the meta line's "Waiting for your answer" text carries it. Idle is the
* the meta line's "Waiting for input" text carries it. Idle is the
* absence of a signal, by design.
*/
function SessionStatus({ busy }: { busy: boolean }) {
@@ -250,7 +250,7 @@ export function timeAgo(iso: string, now = new Date()): string {
* ever wanted. */
function sessionMeta(chat: ChatListItem): string {
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`;
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
}
+134 -134
View File
@@ -1,16 +1,16 @@
{
"name": "learnharness",
"version": "0.3.1",
"version": "0.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.3.1",
"version": "0.3.4",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.0.2",
"@earendil-works/pi-ai": "1.0.2",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2",
@@ -31,7 +31,7 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
"katex": "^0.16.47",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
"media-chrome": "^4.19.2",
@@ -145,16 +145,16 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"version": "3.978.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.5.tgz",
"integrity": "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/types": "^3.975.0",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/core": "^3.36.1",
"@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
@@ -164,14 +164,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz",
"integrity": "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -180,14 +180,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz",
"integrity": "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
@@ -198,21 +198,21 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"version": "3.973.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz",
"integrity": "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/credential-provider-env": "^3.972.76",
"@aws-sdk/credential-provider-http": "^3.972.78",
"@aws-sdk/credential-provider-login": "^3.972.83",
"@aws-sdk/credential-provider-process": "^3.972.76",
"@aws-sdk/credential-provider-sso": "^3.973.20",
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
@@ -222,15 +222,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"version": "3.972.83",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz",
"integrity": "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -239,19 +239,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"version": "3.972.88",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz",
"integrity": "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/credential-provider-env": "^3.972.76",
"@aws-sdk/credential-provider-http": "^3.972.78",
"@aws-sdk/credential-provider-ini": "^3.973.21",
"@aws-sdk/credential-provider-process": "^3.972.76",
"@aws-sdk/credential-provider-sso": "^3.973.20",
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
@@ -261,14 +261,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz",
"integrity": "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -277,16 +277,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"version": "3.973.20",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz",
"integrity": "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/token-providers": "3.1155.0",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -295,15 +295,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"version": "3.1155.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz",
"integrity": "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -312,15 +312,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"version": "3.972.82",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz",
"integrity": "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -329,13 +329,13 @@
}
},
"node_modules/@aws-sdk/eventstream-handler-node": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz",
"integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==",
"version": "3.972.38",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.38.tgz",
"integrity": "sha512-K7vPKNstDlavHFZgbmcFdJOFIyowsevjddycRziRBbtXH/D9z+i9juCnH17liwSX9T8fJiDQzlEYkRtxkR+1vA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -344,13 +344,13 @@
}
},
"node_modules/@aws-sdk/middleware-eventstream": {
"version": "3.972.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz",
"integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==",
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.33.tgz",
"integrity": "sha512-8QUt39D/dk5tdV7rLvIfMMI/ilQff4zth7mkMKpVOLM+M5AQ9W3nPxFVuHQANevmVWi4T5MlxqfKV4Q2drYeBA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -359,16 +359,16 @@
}
},
"node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.54",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz",
"integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==",
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.58.tgz",
"integrity": "sha512-/BvocdloKozx/zFr4benH+9J1ywB/2dYFHPWNUdo0CBCe00gTm0HPflOq8MH6cuCTbnywSrscGq0/LheN59teQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -377,15 +377,15 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"version": "3.997.50",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz",
"integrity": "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/signature-v4-multi-region": "^3.996.49",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
@@ -396,13 +396,13 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"version": "3.996.49",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz",
"integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@aws-sdk/types": "^3.975.0",
"@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -428,9 +428,9 @@
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"version": "3.975.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz",
"integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
@@ -463,9 +463,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -484,12 +484,12 @@
"license": "Apache-2.0"
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.0.2.tgz",
"integrity": "sha512-VRfewY1R5mbedJzlm01PxdCmqU8QrSXjXXhV50bp6rvVuT8YYQv1inTXRY4YzssmGAYpkC2rXipJKP5BpBFXew==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz",
"integrity": "sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^1.0.2",
"@earendil-works/pi-ai": "1.4.0",
"typebox": "1.3.27"
},
"engines": {
@@ -497,14 +497,14 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.0.2.tgz",
"integrity": "sha512-JP59xGlSAQ/HhQ6EHN5qmneBlWfDyu6FPPryAvAaSo8A38ubAsEpS9YisCM5Jbbns0srrDffLZonE0OXorHI5A==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz",
"integrity": "sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^1.0.2",
"@earendil-works/pi-telemetry": "1.4.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@@ -558,9 +558,9 @@
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.0.2.tgz",
"integrity": "sha512-Ev5h9TE8nEXHCfRKhWj0qpUFhQgVqNteS95vsM2KNGRGm533vOO2ruDPaEwImBKPMBKXy4omz3D6V2cm41gLOQ==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz",
"integrity": "sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
@@ -4727,9 +4727,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz",
"integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==",
"version": "3.36.2",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.2.tgz",
"integrity": "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
@@ -11775,9 +11775,9 @@
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz",
"integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.3.1",
"version": "0.3.4",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
@@ -24,8 +24,8 @@
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
},
"dependencies": {
"@earendil-works/pi-agent-core": "1.0.2",
"@earendil-works/pi-ai": "1.0.2",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2",
@@ -46,7 +46,7 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
"katex": "^0.16.47",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
"media-chrome": "^4.19.2",
@@ -70,6 +70,9 @@
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3"
},
"overrides": {
"katex": "$katex"
},
"devDependencies": {
"@next/eslint-plugin-next": "^16.3.3",
"@tailwindcss/postcss": "^4.3.3",
+9 -1
View File
@@ -11,6 +11,14 @@ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
import packageMetadata from "../../package.json" with { type: "json" };
/** App identity is immutable and shared by Models and every native Agent.
* Agent's default Pi attribution otherwise overrides the Models identity. */
export const APP_IDENTITY = Object.freeze({
userAgent: Object.freeze({ custom: `learnharness/${packageMetadata.version}` }),
attribution: Object.freeze({ name: packageMetadata.name, url: packageMetadata.homepage }),
});
/**
* The one engine factory every agent's model comes from (the pi-ai swap,
@@ -47,7 +55,7 @@ let collection: ReturnType<typeof createModels> | undefined;
* keyPresent. */
export function piModels() {
if (!collection) {
collection = createModels();
collection = createModels({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
}
return collection;
+6 -6
View File
@@ -19,7 +19,7 @@ The defining condition of practice: the proposition you are exercising is **inac
Three things this session is not:
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing.
- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
@@ -73,7 +73,7 @@ Interleaving is the default. Never issue blocks of same-target problems.
1. **Target** — per the selection rules.
2. **Mode** — the cheapest undemonstrated layer for that target.
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets.
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
@@ -101,7 +101,7 @@ Three structural rules:
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later.
- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
@@ -109,17 +109,17 @@ The rung a problem needed is a difficulty readout — only R0–R1 means the pro
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on.
**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so.
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.
## VIII. Progression
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.
## IX. The arc
+62 -22
View File
@@ -742,6 +742,20 @@ export interface NotesImportInput {
text: string;
}
/** A notes import did not finish. Earlier graph writes and billed usage are
* retained; partialStats describe known merges, not an all-or-nothing result. */
export class NotesImportError extends Error {
constructor(
public readonly outcome: "failed" | "partial" | "aborted" | "busy",
public readonly partialStats: EmissionStats,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "NotesImportError";
}
}
export interface ExtractionPipeline {
/** Extract + audit + merge ONE chat. Single-flight per chat; resolves to
* the merger's append stats (or throws — no checkpoint on failure). */
@@ -1285,9 +1299,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> {
const { id } = input;
if (inFlight.has(id)) return { nodes: 0, chains: 0 };
inFlight.add(id);
// importNotes owns the source exclusion, including metadata and cleanup.
const t0 = performance.now();
const mergedStats: EmissionStats = { nodes: 0, chains: 0 };
try {
// BYOK per-need gate (owner, 2026-09-22) + key presence
// (2026-09-25): same refusal as the chat front door — no
@@ -1313,11 +1327,15 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const settled: PromiseSettledResult<EmissionStats>[] = [];
for (const [chunkIndex, chunk] of chunks.entries()) {
const part = chunks.length > 1 ? { i: chunkIndex + 1, n: chunks.length } : undefined;
// Keep the actual signal after runJob tears down its registry row:
// usage persistence can throw and mask the body's AbortError.
let chunkSignal: AbortSignal | undefined;
try {
settled.push({
status: "fulfilled",
value: await runJob({ id, kind: "notes", label: input.filePath, part }, async (jobKey) => {
setPhase(jobKey, "queued", "notes", input.filePath, part);
chunkSignal = runningExtractions.get(jobKey)?.controller.signal;
const findings = await withExtractSlot(jobKey, async () => {
setPhase(jobKey, "extracting", "notes", input.filePath, part);
return notesExtract(jobKey, { ...input, text: chunk, part });
@@ -1337,6 +1355,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
{ chatStore, graphStore },
{ foldedAt: input.importedAt },
);
// Capture the committed merge BEFORE layout/usage can fail.
mergedStats.nodes += merged.nodes;
mergedStats.chains! += merged.chains ?? 0;
if (merged.droppedChains)
mergedStats.droppedChains = (mergedStats.droppedChains ?? 0) + merged.droppedChains;
// The layout ledger evolves with each merge — the next
// snapshot fetch reflects every finished chunk.
settleGraphLayout(graphStore);
@@ -1346,46 +1369,63 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
}),
});
} catch (err) {
settled.push({ status: "rejected", reason: err });
let failure = err;
if (chunkSignal?.aborted && !(err instanceof Error && err.name === "AbortError")) {
// Notes-only: never start a fresh chunk after user cancellation,
// even when usage cleanup replaced the original abort rejection.
// Keep the secondary failure visible in the final import error.
const aborted = new Error(`Notes import aborted; job cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
aborted.name = "AbortError";
failure = aborted;
}
settled.push({ status: "rejected", reason: failure });
// Same source-wide-abort law as the chat door: stop the
// run; isolated failures keep going.
if (err instanceof Error && err.name === "AbortError") break;
if (failure instanceof Error && failure.name === "AbortError") break;
}
}
const stats = aggregateJobs(id, settled);
// Completion is a chunk-status contract, never a node-count test.
// Zero findings (and an empty file after the readiness gate) are valid.
const rejected = settled.filter((r) => r.status === "rejected");
if (rejected.length > 0 || settled.length !== chunks.length) {
const aborted = rejected.some((r) => r.reason instanceof Error && r.reason.name === "AbortError");
const outcome = aborted ? "aborted" : settled.some((r) => r.status === "fulfilled") ? "partial" : "failed";
const details = rejected.map((r) => r.reason instanceof Error ? r.reason.message : String(r.reason)).join("; ");
throw new NotesImportError(outcome, mergedStats, details || "Notes import did not complete");
}
console.info(
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
`(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
);
return stats;
} catch (err) {
// Pre-fan-out failures only (stage gate, chunking): job-level
// failures and aborts are absorbed at the fan-out site, per job,
// with their usage records already persisted.
console.error(`[extraction ${id}] notes pipeline failed (${Math.round(performance.now() - t0)}ms):`, err);
throw err;
if (err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError) throw err;
throw new NotesImportError(err instanceof Error && err.name === "AbortError" ? "aborted" : "failed", mergedStats,
err instanceof Error ? err.message : String(err), { cause: err });
} finally {
inFlight.delete(id);
finishExtraction(id); // done or failed — no longer running
}
}
/** Import ONE raw notes file: register the source (deterministic id per
* file path — re-import keeps the same identity and its usage history)
* and run the pipeline over it. Re-import guard (owner, 2026-09-29):
* the file's content fingerprint decides — unchanged text is a TRUE
* no-op (the mappings ledger used to dedupe this at merge time by
* accident); changed text re-runs the whole file. */
/** Every explicit import processes the whole file. Identity, source digest,
* auditor and usage history remain source-scoped; this is not exactly-once. */
async function importNotes(input: NotesImportInput): Promise<EmissionStats> {
const id = `src_${crypto.createHash("sha256").update(input.filePath).digest("hex").slice(0, 12)}`;
const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16);
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`);
return { nodes: 0, chains: 0 };
// Acquire BEFORE source upsert: overlapping contents must not alter metadata.
if (inFlight.has(id)) throw new NotesImportError("busy", { nodes: 0, chains: 0 }, "This file is already being imported. Try again when it finishes.");
inFlight.add(id);
try {
const importedAt = new Date().toISOString();
graphStore.upsertSource({ id, filePath: input.filePath, importedAt });
return await runNotesExtraction({ id, ...input, importedAt });
} catch (err) {
if (err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError) throw err;
throw new NotesImportError("failed", { nodes: 0, chains: 0 }, err instanceof Error ? err.message : String(err), { cause: err });
} finally {
inFlight.delete(id);
}
const importedAt = new Date().toISOString();
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
return runNotesExtraction({ id, ...input, importedAt });
}
return { runExtraction, importNotes };
+5 -1
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
@@ -183,6 +184,7 @@ export async function runPdfReaderAgent(
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
// factory pin to consult (engine.level is the resolved/clamped value).
const reasoning = engine.level;
const sessionId = randomUUID();
const prompt = [
`PDF path: ${pdfPath}`,
`Question (what the teacher needs from this document): ${input.question || "(no question — give a structural overview)"}`,
@@ -207,6 +209,7 @@ export async function runPdfReaderAgent(
INSTRUCTIONS,
messages,
toolDeclarations,
sessionId,
reasoning,
opts.abortSignal,
opts.chatId ? providerEventObserver(opts.chatId, "job:pdfReader") : undefined,
@@ -395,6 +398,7 @@ async function streamOnce(
systemPrompt: string,
messages: Message[],
tools: Tool[],
sessionId: string,
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
onProviderStreamEvent?: ProviderEventObserver,
@@ -403,8 +407,8 @@ async function streamOnce(
// and tool declarations fold into a leading system message.
// normalizeContext is the library's only Context→TranscriptContext entry.
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
sessionId,
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
onProviderStreamEvent: onProviderStreamEvent as never,
} as never);
+5 -1
View File
@@ -1,6 +1,7 @@
import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { piEngineFor, type PiEngine } from "../models.ts";
import { randomUUID } from "node:crypto";
import { APP_IDENTITY, piEngineFor, type PiEngine } from "../models.ts";
import { providerEventObserver } from "../provider-events.ts";
import type { ModelRole } from "../../../config.ts";
@@ -64,6 +65,9 @@ export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRu
// Debug recorder (owner, 2026-10-04): raw provider events per job run.
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, `job:${opts.role}`) : undefined;
const agent = new Agent({
identity: APP_IDENTITY,
// One unsaved conversation per invocation, stable through every turn.
sessionId: randomUUID(),
initialState: {
systemPrompt: opts.systemPrompt,
model: engine.model,
+5 -1
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
@@ -142,6 +143,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
// factory pin to consult (engine.level is the resolved/clamped value).
const reasoning = engine.level;
const sessionId = randomUUID();
const prompt = [
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):",
brief,
@@ -164,6 +166,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
INSTRUCTIONS,
messages,
toolDeclarations,
sessionId,
reasoning,
opts.abortSignal,
opts.chatId ? providerEventObserver(opts.chatId, "job:visualMaker") : undefined,
@@ -317,6 +320,7 @@ async function streamOnce(
systemPrompt: string,
messages: Message[],
tools: Tool[],
sessionId: string,
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
onProviderStreamEvent?: ProviderEventObserver,
@@ -325,8 +329,8 @@ async function streamOnce(
// and tool declarations fold into a leading system message.
// normalizeContext is the library's only Context→TranscriptContext entry.
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
sessionId,
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
onProviderStreamEvent: onProviderStreamEvent as never,
} as never);
+6 -2
View File
@@ -4,7 +4,7 @@ import { invalidInputResult, questionResult } from "./tool-execute.ts";
import { providerEventObserver } from "./provider-events.ts";
import type { Usage } from "@earendil-works/pi-ai";
import { PATHS } from "../../config.ts";
import { piEngineFor, type PiEngine } from "./models.ts";
import { APP_IDENTITY, piEngineFor, type PiEngine } from "./models.ts";
import { effectivePromptSegments } from "./prompt-segments.ts";
import {
askUserQuestionTool,
@@ -116,7 +116,9 @@ export interface AgentBuildOptions {
* fresh per turn, honoring enabled overrides (switch on + file
* exists); absent → the cached pure-source composition. */
promptSwitches?: Record<string, boolean>;
/** Debug: when set AND LH_PROVIDER_EVENTS=1, raw provider stream events
/** Native conversation identity: the existing persisted chat id, reused
* across reconstructed prober, teacher, and practice Agents.
* Debug: when set AND LH_PROVIDER_EVENTS=1, raw provider stream events
* (pre-normalization) append to data/debug/provider-events/
* <chatId>.jsonl via providerEventObserver — the GLM-truncation /
* DeepSeek-id incident layer. Absent or env-off → no observer. */
@@ -177,6 +179,8 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// the normal no-observer path (tests, env-off).
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
const agent = new Agent({
identity: APP_IDENTITY,
sessionId: opts.chatId,
initialState: {
systemPrompt,
model: engine.model,
+4 -2
View File
@@ -202,8 +202,10 @@ export function concludeTool(gate: PracticeGate): AgentTool<typeof concludeInput
label: "conclude",
description:
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
"up in text). Nothing may conclude unless a problem is active.",
"the learner's composer. After the result, say your closing words about the learner's work if useful, then " +
"END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's " +
"explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, " +
"answer their questions about the concluded one. Nothing may conclude unless a problem is active.",
parameters: concludeInputObject,
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
executionMode: "sequential",
+1 -1
View File
@@ -77,7 +77,7 @@ export interface AuthProviderInfo {
*
* EXCLUDED by owner spec — providers whose auth is not a plain key this
* wave: github-copilot (OAuth), amazon-bedrock (AWS creds), google-vertex
* (ADC), azure-openai-responses (needs a base URL), the cloudflare
* (ADC), azure (needs a base URL), the cloudflare
* account-scoped pair. Anthropic keeps ANTHROPIC_API_KEY only — its
* AUTH_TOKEN/OAUTH_TOKEN alternates stay env-only. openai-codex exposes
* no env-key (naturally absent). Providers sharing one env var (moonshotai
+3 -5
View File
@@ -95,11 +95,9 @@ export interface NotesSource {
filePath: string;
/** ISO — when this file was imported (the checkpoint's foldedAt). */
importedAt: string;
/** sha256 of the imported text at the last EXTRACTION run — the
* same-path re-import guard (owner, 2026-09-29): a matching
* fingerprint is a TRUE no-op (no model calls, no usage record);
* a mismatch or a legacy missing stamp re-runs the whole file.
* The mappings ledger used to give this idempotency by accident. */
/** Legacy content fingerprint. Accepted when reading old records, but
* ignored: every explicit notes import processes the whole file. New
* imports do not compute or write this field. No migration is needed. */
contentFp?: string;
/** The run's usage log — same append-only discipline as
* ChatRecord.extractions, idempotent per startedAt. */
+5
View File
@@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
export function repairMathForDisplay(text: string): string {
return healOverescapedCommands(restoreMathEscapes(text));
}
export function restoreMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
+318 -256
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -30,7 +30,7 @@
import { Value } from "typebox/value";
import type { TSchema } from "typebox";
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
import { repairMathForDisplay } from "./math-delims.ts";
import { healEncodedArguments } from "./argument-heal.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -79,7 +79,7 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
* plus "nabla" as letters), so even runs before a command start inside
* balanced math spans collapse to one backslash. */
function restoreMathEscapesDeep(value: unknown): unknown {
if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value));
if (typeof value === "string") return repairMathForDisplay(value);
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
if (typeof value === "object" && value !== null) {
const out: Record<string, unknown> = {};
+1
View File
@@ -8,6 +8,7 @@ export * from "./chat-handler.ts";
export * from "./events-stream.ts";
export * from "./chats.ts";
export * from "./graph.ts";
export { NotesImportError } from "../agent/subagents/extraction.ts";
export * from "./commit-positions.ts";
export * from "./jobs.ts";
export * from "./stores.ts";
+2 -2
View File
@@ -13,7 +13,7 @@
* ALL the env vars a provider reads as API keys.
*
* Owner-spec exclusions (asserted ABSENT): github-copilot (OAuth),
* amazon-bedrock (AWS creds), google-vertex (ADC), azure-openai-responses
* amazon-bedrock (AWS creds), google-vertex (ADC), azure
* (needs base URL), the cloudflare account-scoped pair; anthropic keeps
* ANTHROPIC_API_KEY only (AUTH_TOKEN/OAUTH_TOKEN stay env-only).
*/
@@ -32,7 +32,7 @@ const EXCLUDED_PROVIDERS = new Set([
"github-copilot",
"amazon-bedrock",
"google-vertex",
"azure-openai-responses",
"azure",
"cloudflare-workers-ai",
"cloudflare-ai-gateway",
]);
+224 -20
View File
@@ -22,9 +22,10 @@
*/
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import { after, describe, it } from "node:test";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall, type Usage } from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
@@ -44,6 +45,7 @@ import type { GraphStore } from "../src/core/ports.ts";
import {
abortExtraction,
createExtractionPipeline,
NotesImportError,
extractionStatuses,
transcriptView,
sourceDigest,
@@ -871,21 +873,30 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const verdict: AuditVerdicts = {
links: [{ id: FINDINGS.propositions[0]!.id, linkTo: existingId }],
};
const nativeUsage: Record<"extractor" | "auditor", Usage[]> = { extractor: [], auditor: [] };
const recordUsage = (stage: "extractor" | "auditor", engine: PiEngine): PiEngine => ({
...engine,
streamFn: (model, context, options) => {
const stream = engine.streamFn(model, context, options);
void stream.result().then((message) => nativeUsage[stage].push(message.usage));
return stream;
},
});
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
// During the auditor's generate, the registry must already carry
// the extractor's fully accumulated live usage (2 steps × 10/5).
extractor: submitModel("submit_findings", FINDINGS),
auditor: submitModel("submit_review", verdict, () =>
extractor: recordUsage("extractor", submitModel("submit_findings", FINDINGS)),
auditor: recordUsage("auditor", submitModel("submit_review", verdict, () =>
usageSeen.push(extractionStatuses().find((r) => r.chatId === chatId)?.usage.extractor?.steps.at(-1)?.inputTokens),
),
)),
});
await pipeline.runExtraction(chatId);
// Live: mid-audit the extractor's usage was fully accumulated (faux
// estimates tokens from the prompt/response text — deterministic,
// positive, no cache).
// positive, with native first-conversation cache writes in 1.4).
assert.equal(usageSeen.length, 1);
assert.ok(typeof usageSeen[0] === "number" && usageSeen[0]! > 0, "the extractor's step billed input tokens");
@@ -901,8 +912,16 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const step = run.stages.extractor.steps[0]!;
assert.ok(step.inputTokens! > 0 && step.outputTokens! > 0, "estimated usage captured");
assert.equal(step.cacheReadTokens, 0);
assert.equal(step.cacheWriteTokens, 0);
assert.equal(nativeUsage.extractor.length, 1);
assert.ok(nativeUsage.extractor[0]!.cacheWrite > 0, "fresh native conversation writes its cached prefix");
assert.equal(step.cacheReadTokens, nativeUsage.extractor[0]!.cacheRead);
assert.equal(step.cacheWriteTokens, nativeUsage.extractor[0]!.cacheWrite);
assert.equal(step.reasoningTokens, 0);
assert.equal(nativeUsage.auditor.length, 1);
assert.equal(run.stages.auditor.steps[0]!.cacheReadTokens, 0, "auditor owns a fresh conversation too");
assert.ok(nativeUsage.auditor[0]!.cacheWrite > 0);
assert.equal(run.stages.auditor.steps[0]!.cacheReadTokens, nativeUsage.auditor[0]!.cacheRead);
assert.equal(run.stages.auditor.steps[0]!.cacheWriteTokens, nativeUsage.auditor[0]!.cacheWrite);
assert.equal(run.stages.auditor.steps.length, 1);
assert.ok(!Number.isNaN(Date.parse(run.at)));
});
@@ -1584,7 +1603,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable");
});
it("re-importing is fingerprint-guarded: unchanged text is a TRUE no-op; changed text re-runs (owner, 2026-09-29)", async () => {
it("every explicit import runs the whole file, even identical text and legacy fingerprints", async () => {
const ledgers = memoryLedgers();
const mk = () =>
createExtractionPipeline({
@@ -1596,23 +1615,21 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
assert.deepEqual(first, { nodes: 1, chains: 0 });
// Unchanged text: the content fingerprint matches — no extraction
// at all (no model calls, no usage record; the mappings ledger
// used to give this no-op at merge time by accident).
// A legacy stamp must never suppress an explicit import.
ledgers.upsertSource({ ...ledgers.readSources()[0]!, contentFp: createHash("sha256").update(NOTES_TEXT).digest("hex").slice(0, 16) });
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
assert.deepEqual(second, { nodes: 0, chains: 0 }, "unchanged content is a true no-op");
assert.deepEqual(second, { nodes: 1, chains: 0 }, "identical content re-extracts");
assert.equal(ledgers.readSources()[0]!.contentFp, undefined, "new imports write no fingerprint");
assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "only the real run billed its usage");
assert.equal(ledgers.readNodes().length, 1, "no duplicate node");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "both runs bill usage");
assert.equal(ledgers.readNodes().length, 2, "the auditor, not a content skip, owns identity");
// Changed text under the same path: the fingerprint misses — the
// whole file re-runs (fresh merge; the auditor's links would dedupe
// identity on a real model).
// Changed text also re-runs the whole file.
const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` });
assert.deepEqual(third, { nodes: 1, chains: 0 }, "changed content re-extracts");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "the re-run bills its usage");
assert.equal(ledgers.readNodes().length, 2);
assert.equal(ledgers.readSources()[0]!.extractions?.length, 3, "the re-run bills its usage");
assert.equal(ledgers.readNodes().length, 3);
});
it("aborts like a chat run: true cancellation, nothing merged, partial usage in the source record", async () => {
@@ -1627,7 +1644,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
assert.equal(abortExtraction(srcId), true);
assert.deepEqual(await runPromise, { nodes: 0, chains: 0 });
await assert.rejects(runPromise, (error) => error instanceof NotesImportError && error.outcome === "aborted" && error.partialStats.nodes === 0);
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted");
@@ -1935,7 +1952,7 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
// SOURCE-WIDE abort: the parked job dies and the run ENDS — no
// later chunk ever mints (fresh rows would hang un-aborted).
assert.equal(abortExtraction(srcId), true);
assert.deepEqual(await runPromise, { nodes: 0, chains: 0 });
await assert.rejects(runPromise, (error) => error instanceof NotesImportError && error.outcome === "aborted");
assert.equal(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry");
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
// Per-job usage: the ONE minted job persisted its (failed) spend.
@@ -2318,3 +2335,190 @@ describe("chain simplification (the merger's maintenance pass, owner 2026-09-30
assert.equal(stored.length, 2, "the ledger is back to the two lean chains");
});
});
describe("notes import completion and retry reliability", () => {
const input = { filePath: "reliability.md", text: NOTES_TEXT };
const make = (graphStore: GraphStore, extractor = submitModel("submit_findings", NOTES_FINDINGS)) =>
createExtractionPipeline({ chatStore, graphStore, extractor, auditor: submitModel("submit_review", NOTES_VERDICT) });
const incomplete = (outcome: NotesImportError["outcome"], nodes: number) => (error: unknown) => {
assert.ok(error instanceof NotesImportError);
assert.equal(error.outcome, outcome);
assert.equal(error.partialStats.nodes, nodes);
return true;
};
it("failure followed by an unchanged retry really extracts and preserves both usage records", async () => {
const ledgers = memoryLedgers();
await assert.rejects(make(ledgers, silentModel()).importNotes(input), incomplete("failed", 0));
assert.deepEqual(await make(ledgers).importNotes(input), { nodes: 1, chains: 0 });
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2);
});
it("zero new nodes and an empty file are successful, but the readiness gate still applies", async () => {
const ledgers = memoryLedgers();
assert.deepEqual(await make(ledgers, submitModel("submit_findings", { propositions: [], chains: [] })).importNotes(input), { nodes: 0, chains: 0 });
assert.deepEqual(await make(ledgers).importNotes({ ...input, text: " \n" }), { nodes: 0, chains: 0 });
await assert.rejects(createExtractionPipeline({ chatStore, graphStore: ledgers }).importNotes({ ...input, text: "" }), ModelsNotConfiguredError);
await assert.rejects(createExtractionPipeline({ chatStore, graphStore: ledgers, extractor: submitModel("submit_findings", NOTES_FINDINGS), auditor: submitModel("submit_review", NOTES_VERDICT), providerKeyMissing: () => providerKeyMissing("openai", ["cheap"]) }).importNotes(input), ProviderKeyMissingError);
});
it("isolates an ordinary failed chunk, reports partial, and keeps earlier/later merges and usage", async () => {
const ledgers = memoryLedgers();
let calls = 0;
const pipeline = createExtractionPipeline({
chatStore, graphStore: ledgers, getChunkTokens: () => 1,
engineFor: (role) => role === "auditor" ? submitModel("submit_review", NOTES_VERDICT) : fauxEngine([() => {
calls++;
if (calls === 2) throw new Error("synthetic failed chunk");
return fauxAssistantMessage([fauxToolCall("submit_findings", NOTES_FINDINGS as unknown as ToolCall["arguments"])], { stopReason: "toolUse" });
}]),
});
await assert.rejects(pipeline.importNotes({ ...input, text: "first chunk\nsecond chunk\nthird chunk" }), incomplete("partial", 2));
assert.equal(calls, 3);
assert.equal(ledgers.readNodes().length, 2);
assert.equal(ledgers.readSources()[0]!.extractions?.length, 3);
});
it("an abort after an earlier merge stops later chunks without losing graph or billed usage", async () => {
const ledgers = memoryLedgers();
let calls = 0;
const pipeline = createExtractionPipeline({
chatStore, graphStore: ledgers, getChunkTokens: () => 1,
engineFor: (role) => role === "auditor" ? submitModel("submit_review", NOTES_VERDICT) : submitModel("submit_findings", NOTES_FINDINGS, () => {
calls++;
if (calls === 2) abortExtraction(ledgers.readSources()[0]!.id);
}),
});
await assert.rejects(pipeline.importNotes({ ...input, text: "first chunk\nsecond chunk\nthird chunk" }), incomplete("aborted", 1));
assert.equal(calls, 2);
assert.equal(ledgers.readNodes().length, 1);
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2);
});
for (const abortAt of [1, 2]) it(`abort in chunk ${abortAt} survives a usage-persistence error and stops later chunks`, async () => {
const ledgers = memoryLedgers();
let calls = 0;
let cancelled = false;
let usageFailures = 0;
const graphStore: GraphStore = { ...ledgers, appendSourceUsage: (id, entry) => {
if (cancelled) { usageFailures++; throw new Error("synthetic usage persistence failure during abort"); }
ledgers.appendSourceUsage(id, entry);
} };
const pipeline = createExtractionPipeline({
chatStore, graphStore, getChunkTokens: () => 1,
engineFor: (role) => role === "auditor" ? submitModel("submit_review", NOTES_VERDICT) : submitModel("submit_findings", NOTES_FINDINGS, () => {
calls++;
if (calls === abortAt) {
cancelled = true;
assert.equal(abortExtraction(ledgers.readSources()[0]!.id), true);
}
}),
});
const wholeFile = { ...input, text: "first chunk\nsecond chunk\nthird chunk" };
await assert.rejects(pipeline.importNotes(wholeFile), (error) => {
incomplete("aborted", abortAt - 1)(error);
assert.match((error as NotesImportError).message, /synthetic usage persistence failure during abort/);
return true;
});
assert.equal(calls, abortAt, "no later extractor runs with a fresh controller after cancellation");
assert.equal(usageFailures, 1, "the aborted chunk attempted to persist usage and surfaced its failure");
assert.equal(ledgers.readNodes().length, abortAt - 1, "earlier graph merges retained");
assert.equal(ledgers.readSources()[0]!.extractions?.length, abortAt - 1, "earlier usage retained");
assert.equal(extractionStatuses().length, 0, "cancelled job row removed");
assert.deepEqual(await make(ledgers).importNotes(wholeFile), { nodes: 1, chains: 0 }, "source exclusion released for an explicit unchanged whole-file retry");
assert.equal(ledgers.readSources()[0]!.extractions?.length, abortAt);
});
it("a usage-persistence error without an aborted signal remains an isolated failure and later chunks run", async () => {
const ledgers = memoryLedgers();
let calls = 0;
const graphStore: GraphStore = { ...ledgers, appendSourceUsage: (id, entry) => {
if (calls === 1) throw new Error("synthetic isolated usage failure");
ledgers.appendSourceUsage(id, entry);
} };
const pipeline = createExtractionPipeline({
chatStore, graphStore, getChunkTokens: () => 1,
engineFor: (role) => role === "auditor" ? submitModel("submit_review", NOTES_VERDICT) : submitModel("submit_findings", NOTES_FINDINGS, () => { calls++; }),
});
await assert.rejects(pipeline.importNotes({ ...input, text: "first chunk\nsecond chunk\nthird chunk" }), (error) => {
incomplete("partial", 3)(error);
assert.match((error as NotesImportError).message, /synthetic isolated usage failure/);
return true;
});
assert.equal(calls, 3, "ordinary cleanup failure does not imply cancellation");
assert.equal(ledgers.readNodes().length, 3);
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2);
});
it("overlapping identical or different contents are busy BEFORE source metadata changes, across pipeline instances", async () => {
const ledgers = memoryLedgers();
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
let upserts = 0;
const tracked = { ...ledgers, upsertSource: (source: NotesSource) => { upserts++; ledgers.upsertSource(source); } };
const first = make(tracked, gatedSubmitModel("submit_findings", NOTES_FINDINGS, gate)).importNotes(input);
const sourceBefore = structuredClone(ledgers.readSources());
try {
await assert.rejects(make(tracked).importNotes(input), incomplete("busy", 0));
await assert.rejects(make(tracked).importNotes({ ...input, text: "changed" }), incomplete("busy", 0));
assert.equal(upserts, 1, "busy calls cannot even attempt source metadata writes");
assert.deepEqual(ledgers.readSources(), sourceBefore);
} finally { release(); }
await first;
assert.deepEqual(await make(ledgers).importNotes(input), { nodes: 1, chains: 0 }, "lock released after lifecycle");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2);
});
it("a fulfilled zero-node chunk plus a failed chunk is partial, not failed or completed", async () => {
const ledgers = memoryLedgers();
let calls = 0;
const pipeline = createExtractionPipeline({
chatStore, graphStore: ledgers, getChunkTokens: () => 1,
engineFor: (role) => role === "auditor" ? submitModel("submit_review", NOTES_VERDICT) : fauxEngine([() => {
if (++calls === 2) throw new Error("synthetic failed chunk");
return fauxAssistantMessage([fauxToolCall("submit_findings", { propositions: [], chains: [] })], { stopReason: "toolUse" });
}]),
});
await assert.rejects(pipeline.importNotes({ ...input, text: "first chunk\nsecond chunk" }), incomplete("partial", 0));
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2);
});
it("a merger failure after a node append is incomplete without rolling back that write", async () => {
const ledgers = memoryLedgers();
const broken = { ...ledgers, appendCheckpoint: () => { throw new Error("synthetic checkpoint failure after node append"); } };
await assert.rejects(make(broken).importNotes(input), incomplete("failed", 0));
assert.equal(ledgers.readNodes().length, 1, "already appended graph write retained even though merge never returned stats");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1);
});
for (const seam of ["layout", "usage"] as const) it(`a post-merge ${seam} failure is not false success and reports committed stats`, async () => {
const ledgers = memoryLedgers();
const originalUsage = ledgers.appendSourceUsage;
let failed = false;
const broken: GraphStore = { ...ledgers,
...(seam === "layout" ? { writePositions: () => { throw new Error("synthetic layout failure"); } } : {
appendSourceUsage: (id, entry) => { if (!failed) { failed = true; throw new Error("synthetic usage failure"); } originalUsage(id, entry); },
}),
};
await assert.rejects(make(broken).importNotes(input), incomplete("failed", 1));
assert.equal(ledgers.readNodes().length, 1);
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1);
await make(ledgers).importNotes(input);
});
it("source-upsert failure is typed and releases exclusion for retry", async () => {
const ledgers = memoryLedgers();
await assert.rejects(make({ ...ledgers, upsertSource: () => { throw new Error("synthetic metadata failure"); } }).importNotes(input), incomplete("failed", 0));
await make(ledgers).importNotes(input);
});
for (const filePath of ["1.md", "1.1.md", "1 notes.md", "1.1 notes.md"]) it(`imports and repeats identical text for ${filePath}`, async () => {
const ledgers = memoryLedgers();
await make(ledgers).importNotes({ ...input, filePath });
await make(ledgers).importNotes({ ...input, filePath });
assert.equal(ledgers.readSources().length, 1);
assert.equal(ledgers.readSources()[0]!.extractions?.length, 2);
assert.equal(ledgers.readNodes().length, 2);
});
});
+83
View File
@@ -0,0 +1,83 @@
/** GHSA-238p-pmpm-9mq7: inherited properties must not become KaTeX authority.
* Output is inspected as inert strings/LinkeDOM; no resource or script is run.
* node:test runs files in separate processes. Within this file all prototype
* mutations are synchronous, non-concurrent, and restored even on failure.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import katex from "katex";
import { MessageResponse } from "../components/ai-elements/message.tsx";
(globalThis as Record<string, unknown>).React = React;
const blocked = String.raw`\href{https://katex-security.invalid/blocked}{blocked}`;
function withoutAuthority(html: string) {
const { document } = parseHTML(html);
assert.equal(document.querySelectorAll("[href], [src], a, img, script, iframe").length, 0, "inherited authority must not emit active HTML or URL attributes");
assert.ok(html.includes("blocked"), "the formula was actually processed");
}
function withPrototype(key: string, value: unknown, run: () => void) {
const previous = Object.getOwnPropertyDescriptor(Object.prototype, key);
try {
Object.defineProperty(Object.prototype, key, { value, configurable: true, writable: true });
run();
} finally {
if (previous) Object.defineProperty(Object.prototype, key, previous);
else Reflect.deleteProperty(Object.prototype, key);
}
}
function markdown() {
return renderToStaticMarkup(React.createElement(MessageResponse, { mode: "static" }, `$${blocked}$`));
}
describe("KaTeX prototype read-side security", { concurrency: false }, () => {
it("ignores Object.prototype.trust in direct and app markdown rendering", () => {
withPrototype("trust", true, () => {
withoutAuthority(katex.renderToString(blocked, { strict: false }));
withoutAuthority(markdown());
});
});
it("ignores trust inherited from a custom options prototype", () => {
const options = Object.assign(Object.create({ trust: true }), { strict: false });
withoutAuthority(katex.renderToString(blocked, options));
});
it("ignores inherited schema defaults in direct and app markdown rendering", () => {
withPrototype("default", true, () => {
withoutAuthority(katex.renderToString(blocked, { strict: false }));
withoutAuthority(markdown());
});
});
it("ignores inherited schema processors, including for own trust:false", () => {
withPrototype("processor", (value: unknown) => value === false ? true : value, () => {
withoutAuthority(katex.renderToString(blocked, { strict: false, trust: false }));
withoutAuthority(markdown());
});
});
it("keeps own trust:false and own restricted callbacks restrictive", () => {
withPrototype("trust", true, () => {
withoutAuthority(katex.renderToString(blocked, { strict: false, trust: false }));
const commands: string[] = [];
withoutAuthority(katex.renderToString(blocked, {
strict: false,
trust: (context) => { commands.push(context.command); return context.command === "\\url"; },
}));
assert.deepEqual(commands, ["\\href"]);
});
});
it("ignores inherited builtin namespace entries", () => {
withPrototype("\\securityInherited", "INHERITED", () => {
// The parser may reject it as an unknown function rather than an
// undefined command; either way the inherited macro must not expand.
assert.throws(() => katex.renderToString(String.raw`\securityInherited`), /KaTeX parse error/);
});
});
it("does not restore an inherited macro as an own definition after a group", () => {
const macros = Object.create({ "\\securityRestore": "INHERITED" });
const html = katex.renderToString(String.raw`{\def\securityRestore{L}\securityRestore}`, { macros });
assert.ok(parseHTML(html).document.querySelector(".katex-html")?.textContent?.includes("L"));
assert.equal(Object.hasOwn(macros, "\\securityRestore"), false, "group restoration must delete, not promote an inherited value");
assert.throws(() => katex.renderToString(String.raw`\securityRestore`, { macros }), /Undefined control sequence/);
});
});
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { after, it } from "node:test";
import { POST } from "../app/api/graph/import/route.ts";
import { extraction } from "../src/server/index.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../src/core/settings.ts";
import { createExtractionPipeline, NotesImportError } from "../src/agent/subagents/extraction.ts";
import { createFauxCore, fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { stores } from "../src/server/stores.ts";
import type { GraphStore } from "../src/core/ports.ts";
import type { GraphNode, GraphChain, NotesSource, SessionCheckpoint } from "../src/core/contracts.ts";
const original = extraction.importNotes;
after(() => { extraction.importNotes = original; });
function request(names: string[]) {
const form = new FormData();
for (const name of names) form.append("files", new File(["identical text"], name));
return new Request("http://localhost/api/graph/import", { method: "POST", body: form });
}
it("route keeps three-worker batching, request positions and mixed per-file outcomes/config details", async () => {
let active = 0;
let peak = 0;
extraction.importNotes = async ({ filePath }) => {
active++; peak = Math.max(peak, active);
try {
await new Promise((resolve) => setTimeout(resolve, filePath === "slow.md" ? 25 : 1));
if (filePath === "models.md") throw new ModelsNotConfiguredError(["cheap"]);
if (filePath === "key.md") throw new ProviderKeyMissingError("openai", ["cheap"]);
if (["failed", "partial", "aborted", "busy"].includes(filePath)) throw new NotesImportError(filePath as NotesImportError["outcome"], { nodes: 2, chains: 1 }, `synthetic ${filePath}`);
if (filePath === "unknown.md") throw new Error("synthetic unknown error");
return { nodes: 0, chains: 0 };
} finally { active--; }
};
const names = ["slow.md", "same.md", "same.md", "failed", "partial", "aborted", "busy", "models.md", "key.md", "unknown.md"];
const response = await POST(request(names));
assert.equal(response.status, 200);
const { results } = await response.json();
assert.equal(peak, 3);
assert.notEqual(results[0].requestIndex, 0, "out-of-order completion remains supported");
assert.equal(results.length, names.length);
for (const [requestIndex, filePath] of names.entries()) {
const result = results.find((r: { requestIndex: number }) => r.requestIndex === requestIndex);
assert.equal(result.filePath, filePath);
if (["failed", "partial", "aborted", "busy"].includes(filePath)) {
assert.equal(result.outcome, filePath);
assert.equal(result.ok, false);
assert.deepEqual(result.stats, { nodes: 2, chains: 1 });
} else if (["slow.md", "same.md"].includes(filePath)) {
assert.equal(result.outcome, "completed"); assert.equal(result.ok, true);
} else { assert.equal(result.ok, false); assert.equal(result.outcome, "failed"); }
}
assert.deepEqual(results.find((r: { filePath: string }) => r.filePath === "models.md").missing, ["cheap"]);
const key = results.find((r: { filePath: string }) => r.filePath === "key.md");
assert.equal(key.code, "provider_key_missing"); assert.equal(key.provider, "openai"); assert.deepEqual(key.slots, ["cheap"]);
});
it("real route plus in-memory ledgers and faux stages reprocess identical imports for all four filename cases", async () => {
const nodes: GraphNode[] = [];
const chains: GraphChain[] = [];
const checkpoints: SessionCheckpoint[] = [];
const sources = new Map<string, NotesSource>();
const graphStore: GraphStore = {
appendNode: (node) => { nodes.push(node); }, readNodes: () => [...nodes],
appendChain: (chain) => { chains.push(chain); }, readChains: () => [...chains],
appendCheckpoint: (checkpoint) => { checkpoints.push(checkpoint); }, readCheckpoints: () => [...checkpoints],
appendCoverage: () => {}, readCoverage: () => [],
upsertSource: (source) => { sources.set(source.id, { ...source, extractions: sources.get(source.id)?.extractions ?? [] }); },
readSources: () => [...sources.values()],
appendSourceUsage: (id, usage) => { sources.get(id)!.extractions!.push(usage); },
readPositions: () => ({}), writePositions: () => {}, rewriteLedgers: () => {},
};
let calls = 0;
const pipeline = createExtractionPipeline({ chatStore: stores.chatStore, graphStore,
engineFor: (role) => {
const faux = createFauxCore({});
faux.setResponses([() => {
if (role === "extractor") calls++;
return fauxAssistantMessage([fauxToolCall(role === "extractor" ? "submit_findings" : "submit_review", role === "extractor" ? {
propositions: [{ id: "p1", name: "Synthetic import", statement: "Explicit notes imports process the whole file.", aliases: [] }], chains: [],
} : { links: [] })], { stopReason: "toolUse" });
}]);
return { model: faux.getModel(), streamFn: faux.streamSimple };
},
});
extraction.importNotes = pipeline.importNotes;
const names = ["1.md", "1.1.md", "1 notes.md", "1.1 notes.md"];
for (let pass = 0; pass < 2; pass++) {
const response = await POST(request(names));
const { results } = await response.json();
assert.equal(results.length, 4);
assert.ok(results.every((r: { ok: boolean; outcome: string }) => r.ok && r.outcome === "completed"));
}
assert.equal(calls, 8, "repeat identical imports execute real faux extractor calls");
assert.equal(nodes.length, 8);
assert.equal(sources.size, 4);
assert.ok([...sources.values()].every((source) => source.extractions?.length === 2 && source.contentFp === undefined));
});
it("route returns 400 without files", async () => {
const response = await POST(request([]));
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { results: [], error: "no files" });
});
+200
View File
@@ -0,0 +1,200 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, it } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { NotesImportErrors, parseNotesImportResults, postNotesImport, useNotesImport, type NotesImportResult } from "../components/notes-import.tsx";
// Real hook/React state, isolated DOM and wholly stubbed import transport.
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined;
let host: HTMLDivElement | undefined;
let current: ReturnType<typeof useNotesImport>;
let starts = 0;
let finishes = 0;
async function mount() {
starts = 0;
finishes = 0;
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
function Harness() {
current = useNotesImport({ onStart: () => { starts++; }, onFinish: () => { finishes++; } });
return <NotesImportErrors errors={current.errors} />;
}
await act(async () => { root!.render(<Harness />); });
}
async function submit(selected: File[] | null = [...files]) {
await act(async () => { current.onImportFiles(selected as unknown as FileList | null); });
}
afterEach(async () => {
await act(async () => { root?.unmount(); });
root = undefined;
host?.remove();
host = undefined;
globalThis.fetch = originalFetch;
});
const files = [new File(["one"], "same.md"), new File(["two"], "same.md")];
const completed = (requestIndex = 0): NotesImportResult => ({ requestIndex, filePath: "same.md", ok: true, outcome: "completed", stats: { nodes: 0, chains: 0 } });
it("maps out-of-order responses by index, not duplicate basenames", () => {
const results = [completed(1), { ...completed(0), stats: { nodes: 7 } }];
assert.deepEqual(parseNotesImportResults({ results }, files), results);
});
for (const [name, results] of [
["missing index", [{ ...completed(), requestIndex: undefined }, completed(1)]],
["duplicate index", [completed(), completed()]],
["missing row", [completed()]],
["out-of-range index", [completed(), completed(2)]],
["wrong filename", [{ ...completed(), filePath: "different.md" }, completed(1)]],
["missing stats", [{ ...completed(), stats: undefined }, completed(1)]],
["unknown outcome", [{ ...completed(), outcome: "unchanged" }, completed(1)]],
["conflicting ok", [{ ...completed(), ok: false }, completed(1)]],
["invalid stats", [{ ...completed(), stats: { nodes: "bad" } }, completed(1)]],
["invalid details", [{ ...completed(), slots: [42] }, completed(1)]],
["invalid provider", [{ ...completed(), provider: 42 }, completed(1)]],
["invalid code", [{ ...completed(), code: 42 }, completed(1)]],
["invalid missing", [{ ...completed(), missing: [42] }, completed(1)]],
["invalid error", [{ ...completed(), error: 42 }, completed(1)]],
["missing failure error", [{ ...completed(), ok: false, outcome: "failed" }, completed(1)]],
] as const) it(`malformed response (${name}) is rejected for retry`, () => {
assert.throws(() => parseNotesImportResults({ results }, files), /Please retry/);
});
it("POST sends only captured files, and retains config/provider details", async () => {
const refused: NotesImportResult = { requestIndex: 0, filePath: files[1]!.name, ok: false, outcome: "failed", code: "provider_key_missing", provider: "openai", slots: ["cheap"], error: "No key is set" };
globalThis.fetch = async (url, init) => {
assert.equal(url, "/api/graph/import"); assert.equal(init?.method, "POST");
const sent = (init?.body as FormData).getAll("files") as File[];
assert.equal(sent.length, 1); assert.equal(await sent[0]!.text(), "two");
return Response.json({ results: [refused] });
};
assert.deepEqual(await postNotesImport([files[1]!]), [refused]);
});
for (const kind of ["network", "non-JSON", "non-2xx", "malformed"] as const) it(`${kind} response rejects rather than false success`, async () => {
globalThis.fetch = async () => {
if (kind === "network") throw new Error("synthetic offline");
if (kind === "non-JSON") return new Response("not JSON");
if (kind === "non-2xx") return Response.json({ error: "synthetic refused" }, { status: 503 });
return Response.json({ results: [] });
};
await assert.rejects(postNotesImport([files[0]!]));
});
for (const outcome of ["completed", "failed", "partial", "aborted", "busy"] as const) it(`${outcome} leaves only escaped raw failure text, without results or controls`, async () => {
const file = new File(["notes"], "<script>notes.md");
const result: NotesImportResult = { ...completed(), filePath: file.name, outcome, ok: outcome === "completed", error: "<img>synthetic failure", code: "provider_key_missing", provider: "openai", missing: ["OPENAI_API_KEY"], slots: ["cheap", "strong"] };
globalThis.fetch = async () => Response.json({ results: [result] });
await mount();
await submit([file]);
assert.equal(current.importing, false);
assert.equal(starts, 1);
assert.equal(finishes, 1);
const html = host!.innerHTML;
assert.doesNotMatch(html, /<button|<section|<ul|border|bg-card|rounded|shadow|Retry|Dismiss|Import complete|new propositions|Saved changes|Import stopped|already being imported/);
assert.deepEqual(Object.keys(current).sort(), ["errors", "importing", "onImportFiles"], "no retained rows, Files, retry or dismiss API");
if (outcome === "completed") {
assert.equal(html, "");
assert.deepEqual(current.errors, []);
} else {
assert.deepEqual(current.errors, [`${file.name}: ${result.error}\nprovider_key_missing — openai — OPENAI_API_KEY — cheap, strong`]);
assert.match(html, /&lt;script&gt;notes.md: &lt;img&gt;synthetic failure/);
assert.match(html, /provider_key_missing — openai — OPENAI_API_KEY — cheap, strong/);
assert.match(html, /role="alert"/);
assert.match(html, /text-xs text-red-400/);
assert.match(html, /whitespace-pre-wrap break-words/);
assert.doesNotMatch(html, /<script>|<img>/);
}
});
for (const details of [
{ code: "models_not_configured", missing: ["cheap", "strong", "vision"] },
{ code: "provider_key_missing", provider: "openai", slots: ["cheap"] },
{ code: "provider_key_missing" },
{},
]) it(`keeps existing configuration detail strings (${JSON.stringify(details)})`, async () => {
globalThis.fetch = async () => Response.json({ results: [{ requestIndex: 0, filePath: "same.md", ok: false, outcome: "failed", error: "Existing raw error", ...details }] });
await mount();
await submit([files[0]!]);
const suffix = details.code === "models_not_configured" ? "\nmodels_not_configured — cheap, strong, vision" : details.provider ? "\nprovider_key_missing — openai — cheap" : details.code ? "\nprovider_key_missing" : "";
assert.deepEqual(current.errors, [`same.md: Existing raw error${suffix}`]);
});
it("empty error text renders nothing; multiple errors are plain independent alerts", () => {
assert.equal(renderToStaticMarkup(<NotesImportErrors errors={[]} />), "");
const html = renderToStaticMarkup(<NotesImportErrors errors={["one.md: failure", "two.md: failure"]} />);
assert.equal((html.match(/role="alert"/g) ?? []).length, 2);
assert.doesNotMatch(html, /<section|<button|border|bg-/);
});
it("mixed out-of-order results show only failed files in selection order, including duplicate basenames", async () => {
const selected = [new File(["success"], "success.md"), ...files];
const results: NotesImportResult[] = [
{ requestIndex: 2, filePath: "same.md", ok: false, outcome: "partial", error: "second duplicate" },
{ ...completed(), filePath: "success.md" },
{ requestIndex: 1, filePath: "same.md", ok: false, outcome: "failed", error: "first duplicate" },
];
globalThis.fetch = async () => Response.json({ results });
await mount();
await submit(selected);
assert.deepEqual(current.errors, ["same.md: first duplicate", "same.md: second duplicate"]);
assert.doesNotMatch(host!.innerHTML, /success.md|completed|propositions/);
});
for (const kind of ["network", "non-JSON", "non-2xx", "malformed", "duplicate index"] as const) it(`${kind} hook failure is visible for every selected file, never success`, async () => {
globalThis.fetch = async () => {
if (kind === "network") throw new Error("synthetic offline");
if (kind === "non-JSON") return new Response("not JSON");
if (kind === "non-2xx") return Response.json({ error: "synthetic refused" }, { status: 503 });
return Response.json({ results: kind === "duplicate index" ? [completed(), completed()] : [] });
};
await mount();
await submit();
const message = kind === "network" ? "synthetic offline" : kind === "non-2xx" ? "Import request failed (HTTP 503): synthetic refused. Please retry the file." : "Invalid import response. Please retry the file.";
assert.deepEqual(current.errors, files.map((file) => `${file.name}: ${message}`));
assert.equal(host!.querySelectorAll('[role="alert"]').length, 2);
assert.equal(finishes, 1);
assert.equal(current.importing, false);
});
it("actual attempts clear errors immediately, guard synchronous reentry, preserve callbacks and allow same-file reimport", async () => {
let requests = 0;
let resolve!: (response: Response) => void;
globalThis.fetch = async () => { requests++; return Response.json({ results: [] }); };
await mount();
await submit();
assert.equal(current.errors.length, 2);
await submit(null);
await submit([]);
assert.equal(current.errors.length, 2, "cancel/empty selection is not an attempt");
assert.equal(requests, 1);
globalThis.fetch = async () => { requests++; return new Promise<Response>((done) => { resolve = done; }); };
await act(async () => {
const staleHandler = current.onImportFiles;
staleHandler(files as unknown as FileList);
staleHandler(files as unknown as FileList);
});
assert.equal(requests, 2, "ref guard rejects even same-tick reentry before React rerenders");
assert.equal(current.importing, true);
assert.deepEqual(current.errors, []);
assert.equal(host!.innerHTML, "");
assert.equal(starts, 2);
assert.equal(finishes, 1, "finish waits for the POST");
await submit();
assert.equal(requests, 2, "pending attempts do not submit");
await act(async () => { resolve(Response.json({ results: [completed(1), completed()] })); });
assert.equal(current.importing, false);
assert.deepEqual(current.errors, []);
assert.equal(finishes, 2);
globalThis.fetch = async () => { requests++; return Response.json({ results: [completed(), completed(1)] }); };
await submit();
assert.equal(requests, 3, "same File objects can be submitted again after settlement");
assert.equal(starts, 3);
assert.equal(finishes, 3);
assert.equal(host!.innerHTML, "");
});
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts";
import { callCost } from "../src/core/prices.ts";
const specimens = [
{ id: "recursive/flint-v1.0:free", input: ["text"] },
{ id: "xingchen-agi/xing4.0-29b-a4b:free", input: ["text"] },
{ id: "nvidia/nemotron-3.5-super-vl:free", input: ["text", "image"] },
{ id: "openrouter/free", input: ["text", "image"] },
];
describe("native 1.4 OpenRouter free catalog (offline, no live discovery)", { concurrency: false }, () => {
for (const { id, input } of specimens) {
it(`${id}: shared listing, validation, tool-capable engine, native modality, and zero cost`, async () => {
const before = process.env;
const originalFetch = globalThis.fetch;
process.env = { NODE_ENV: "test", HOME: "/nonexistent/free-catalog", OPENROUTER_API_KEY: "dummy-free-catalog" };
try {
const catalog = modelCatalog();
const listed = catalog.models.find((m) => m.provider === "openrouter" && m.id === id);
assert.ok(listed);
assert.deepEqual(listed.input, input);
assert.deepEqual(listed.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
const model = { provider: "openrouter", id };
assert.deepEqual(validateChatSettings({ model }), { ok: true });
assert.deepEqual(validateModelsSettings({ strong: { model }, cheap: { model } }), { ok: true });
assert.equal(validateModelsSettings({ vision: { model } }).ok, true, "server catalog validation stays provider/model based; vision filtering belongs to the picker");
assert.equal(catalog.models.filter((m) => m.input.includes("image")).some((m) => m.provider === "openrouter" && m.id === id), input.includes("image"));
const engine = piEngineForSpec(model);
assert.equal(engine.model, piModels().getModel("openrouter", id));
assert.equal(engine.model.api, "openai-completions");
assert.deepEqual(engine.model.input, input);
assert.equal(callCost({ inputTokens: 1_000_000, outputTokens: 500_000, cacheReadTokens: 100_000, cacheWriteTokens: 50_000 }, `openrouter:${id}`), 0);
let payload: Record<string, unknown> | undefined;
globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
assert.equal(new URL(request.url).origin, "https://openrouter.ai");
assert.equal(request.headers.get("authorization"), "Bearer dummy-free-catalog");
payload = JSON.parse(await request.text());
return new Response("{}", { status: 400, headers: { "content-type": "application/json" } });
};
await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }], tools: [{ name: "offline_tool", description: "offline", parameters: Type.Object({}) }] }), { sessionId: "chat_free_offline" }).result();
assert.equal(payload?.model, id);
assert.match(JSON.stringify(payload?.tools), /offline_tool/);
delete process.env.OPENROUTER_API_KEY;
assert.ok(!modelCatalog().models.some((m) => m.provider === "openrouter"), "even free models retain the provider key gate");
} finally { process.env = before; globalThis.fetch = originalFetch; }
});
}
it("removed/unknown native models remain unpriced and unresolvable, never aliased to a paid fallback", () => {
assert.equal(callCost({ inputTokens: 1, outputTokens: 1 }, "openrouter:removed/no-such-model"), null);
assert.throws(() => piEngineForSpec({ provider: "openrouter", id: "removed/no-such-model" }), /catalog has no model/);
});
});
+89 -1
View File
@@ -8,6 +8,7 @@
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
@@ -17,7 +18,8 @@ import {
PRACTICE_PROBLEM_TOOL,
type LhMessage,
} from "../src/core/native-messages.ts";
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
import { concludeInputObject, concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
@@ -235,6 +237,92 @@ describe("the practice agent", () => {
});
});
// ── persistent nudges (shipped defaults, no model calls) ──────────────────────
describe("persistent practice nudges", () => {
const doctrine = readFileSync(new URL("../src/agent/prompts/practice/10-doctrine.md", import.meta.url), "utf8");
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
const approvedParagraphs = [
"- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.",
"**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.",
"Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.",
];
it("preserves the approved paragraphs in source and the effective default prompt", () => {
// Pin approved wording and Markdown, but allow incidental line wrapping.
for (const text of [doctrine, composeInstructions("practice")]) {
const paragraphs = text.split(/\n\s*\n|\n(?=- )/).map(normalizeWhitespace);
for (const approved of approvedParagraphs) {
assert.ok(paragraphs.includes(approved), `missing approved paragraph: ${approved}`);
}
}
});
it("does not compose the retired attempt cutoff, failed-repair reveal, or automatic-abort instructions", () => {
for (const text of [doctrine, composeInstructions("practice")]) {
const normalized = normalizeWhitespace(text);
assert.doesNotMatch(normalized, /\b(?:one|single|1) bounded attempt\b/i);
assert.doesNotMatch(normalized, /If the repair attempt fails, reveal and move on/i);
assert.doesNotMatch(normalized, /A correction found by the learner after a confident error consolidates more/i);
assert.doesNotMatch(normalized, /when the trace shows genuine inability, drop straight to R5 and reveal/i);
assert.doesNotMatch(normalized, /when the learner surrenders or cannot proceed/i);
assert.doesNotMatch(normalized, /when the marginal yield of staying is exhausted/i);
assert.doesNotMatch(normalized, /do not withhold them past the point of exhaustion/i);
}
});
it("preserves the approved concluded-problem switch and unbounded structural repair in source and the effective default prompt", () => {
const approvedDifficulty = "3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.";
const approvedClassification = "Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.";
for (const text of [doctrine, composeInstructions("practice")]) {
const normalized = normalizeWhitespace(text);
assert.ok(normalized.includes(approvedDifficulty), "preserve the difficulty readout and exact approved switching rule");
assert.ok(normalized.includes(approvedClassification), "preserve the miss taxonomy and exact approved structural-repair rule");
assert.doesNotMatch(normalized, /two misses in a row, switch targets/i);
assert.doesNotMatch(normalized, /Structural gets one surgical line/i);
assert.doesNotMatch(normalized, /the correction, stated once/i);
assert.doesNotMatch(normalized, /if a repair needs more than a line/i);
}
});
it("preserves the approved misconception introduction without an affirmative one-line repair cap", () => {
const approvedIntroduction = "- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.";
for (const text of [doctrine, composeInstructions("practice")]) {
const normalized = normalizeWhitespace(text);
assert.ok(normalized.includes(approvedIntroduction), "preserve the exact approved introduction and the rest of the not-teaching bullet");
assert.doesNotMatch(normalized, /Repair them in a line, surgically, and return to practice\./i);
}
});
it("keeps the many-claims default, hint ladder, dismissal, and explicit-go-ahead pause", () => {
const prompt = normalizeWhitespace(composeInstructions("practice"));
assert.match(prompt, /A problem may take many claims; staying is the default/);
for (const rung of ["R0", "R1", "R2", "R3", "R4", "R5"]) {
assert.ok(prompt.includes(`**${rung} —`));
}
assert.match(prompt, /Concluding ends your turn; the next problem is posed only when the learner explicitly moves on/);
assert.match(prompt, /After the conclude's result.*END YOUR TURN: no further tool calls in that reply, never another problem/);
assert.match(prompt, /A clarification question is never the go-ahead; when in doubt, answer and wait/);
assert.match(prompt, /The learner may discard a problem without concluding it/);
});
it("aligns the conclude description with the canonical ack without changing outcomes or the runtime pause mechanism", async () => {
const tool = concludeTool(createPracticeGate(ctxOf(activeProblemRecord())));
const description = normalizeWhitespace(tool.description);
assert.match(description, /records the outcome \+ verdict, reveals the committed solution, and frees the learner's composer/);
assert.match(description, /After the result, say your closing words about the learner's work if useful, then END YOUR TURN — no further tool calls/);
assert.ok(description.includes(CONCLUDE_ACK_TEXT.slice(CONCLUDE_ACK_TEXT.indexOf("The next problem"))));
assert.match(description, /Nothing may conclude unless a problem is active/);
assert.doesNotMatch(description, /turn CONTINUES|reveal, then pose the next problem/);
assert.deepEqual(concludeInputObject.properties.outcome.anyOf.map((outcome) => outcome.const), [
"solved", "solvedWithHints", "movedOn", "surrendered", "dismissed",
]);
const result = await tool.execute("call_persistent_nudges", CONCLUDE_INPUT as never);
assert.equal((result.content[0] as { text: string }).text, CONCLUDE_ACK_TEXT);
assert.equal(result.terminate, undefined, "closing prose stays possible; the pause remains an instruction, not a new runtime gate");
});
});
// ── the prompt composition ──────────────────────────────────────────────────
describe("practice prompt composition (own directory + the formatting appendix)", () => {
+338
View File
@@ -0,0 +1,338 @@
/**
* Raw-stream credential regression: the Sept 26 Agent getApiKey-hook tests
* do not exercise these two hand-rolled loops. All credentials here are
* synthetic; fetch is CLOSED (never delegates), including Google's global
* fetch-only adapter. The transport cases test SDK serialization, not accounts.
*/
import assert from "node:assert/strict";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { describe, it } from "node:test";
import {
createFauxCore, createModels, fauxAssistantMessage, fauxText, fauxToolCall,
type JsonObject, type TranscriptContext, type Usage,
} from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { APP_IDENTITY, piEngineForSpec, type PiEngine } from "../src/agent/models.ts";
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
import { AUTH_PROVIDER_KEYS } from "../src/core/auth-keys.ts";
import { providerEventsDir } from "../src/agent/provider-events.ts";
const OR_KEY = "dummy-openrouter-MUST-NOT-REACH-other-endpoints";
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect width="20" height="20" fill="white"/></svg>';
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", "base64");
const FINAL = "Verified offline output (p. 1). Pages consulted: 1";
const LOOPS = ["visual", "pdf"] as const;
type Loop = typeof LOOPS[number];
type Call = { name: string; args: JsonObject };
interface Transport {
provider: string; id: string; envVar: string; api: string;
origin: string; path: string; authHeader: string;
}
const TRANSPORTS: Transport[] = [
{ provider: "opencode-go", id: "claude-haiku-5-5", envVar: "OPENCODE_API_KEY", api: "anthropic-messages", origin: "https://opencode.ai", path: "/inference/go/v1/messages", authHeader: "x-api-key" },
{ provider: "opencode-go", id: "gemini-4-argon-contributor", envVar: "OPENCODE_API_KEY", api: "google-generative-ai", origin: "https://opencode.ai", path: "/inference/go/v1/models/gemini-4-argon-contributor:streamGenerateContent", authHeader: "x-goog-api-key" },
{ provider: "opencode-go", id: "glm-5.3", envVar: "OPENCODE_API_KEY", api: "openai-completions", origin: "https://opencode.ai", path: "/inference/go/v1/chat/completions", authHeader: "authorization" },
{ provider: "opencode-go", id: "gpt-6-luna", envVar: "OPENCODE_API_KEY", api: "openai-responses", origin: "https://opencode.ai", path: "/inference/go/v1/responses", authHeader: "authorization" },
{ provider: "openrouter", id: "openai/gpt-4o-mini", envVar: "OPENROUTER_API_KEY", api: "openai-completions", origin: "https://openrouter.ai", path: "/api/v1/chat/completions", authHeader: "authorization" },
{ provider: "openai", id: "gpt-4o-mini", envVar: "OPENAI_API_KEY", api: "openai-responses", origin: "https://api.openai.com", path: "/v1/responses", authHeader: "authorization" },
{ provider: "anthropic", id: "claude-haiku-4-5", envVar: "ANTHROPIC_API_KEY", api: "anthropic-messages", origin: "https://api.anthropic.com", path: "/v1/messages", authHeader: "x-api-key" },
{ provider: "google", id: "gemini-2.5-flash", envVar: "GEMINI_API_KEY", api: "google-generative-ai", origin: "https://generativelanguage.googleapis.com", path: "/v1beta/models/gemini-2.5-flash:streamGenerateContent", authHeader: "x-goog-api-key" },
];
// Replacing rather than extending env also isolates secondary tokens (including
// ANTHROPIC_AUTH_TOKEN/OAUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN), SDK base-URL
// overrides, cloud credentials, profiles, and LH_IGNORE_ENV_KEYS. No .env loads.
async function isolated<T>(env: Record<string, string>, run: () => Promise<T>): Promise<T> {
const previous = process.env;
const fetch = globalThis.fetch;
const previousEventsDir = providerEventsDir.current;
const eventsDir = mkdtempSync(path.join(tmpdir(), "lh-provider-neutral-"));
providerEventsDir.current = eventsDir;
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected fetch"); };
process.env = { NODE_ENV: "test", HOME: "/nonexistent/provider-neutral-test-home", ...env };
try {
return await run();
} finally {
process.env = previous;
globalThis.fetch = fetch;
providerEventsDir.current = previousEventsDir;
rmSync(eventsDir, { recursive: true, force: true });
}
}
function callsFor(loop: Loop, round: number): Call[] {
if (round === 2) return [];
if (loop === "visual") return round === 0
? [{ name: "write_svg", args: { svg: SVG } }]
: [{ name: "render_svg", args: {} }];
return round === 0
? [{ name: "pdf_info", args: {} }]
: [{ name: "pdf_extract", args: { pages: "1" } }, { name: "pdf_render", args: { pages: "1", dpi: 100 } }];
}
function fakeHeadless(renders: string[]): HeadlessApi {
return {
available: () => true,
unavailableReason: () => null,
runDemo: async () => { throw new Error("unexpected demo"); },
renderSvg: async (svg) => {
assert.equal(svg, SVG);
renders.push("svg");
return PNG;
},
renderPdfPages: async (file, pages, opts) => {
assert.equal(file, FIXTURE);
assert.deepEqual(pages, [1]);
assert.equal(opts?.dpi, 100);
renders.push("pdf:1");
return [{ page: 1, png: PNG }];
},
};
}
async function runLoop(loop: Loop, engine: PiEngine, signal = new AbortController().signal) {
const renders: string[] = [];
const activities: string[] = [];
const usages: Usage[] = [];
const opts = {
engine, headless: fakeHeadless(renders), abortSignal: signal,
onActivity: (line: string) => activities.push(line),
onUsage: (usage: Usage) => usages.push(usage),
// Direct recording cases enable the observer into an isolated temp dir.
chatId: "provider-neutral-offline",
};
const result = loop === "visual"
? await runVisualMaker({ brief: "A white square, no external resources" }, opts)
: await runPdfReaderAgent({ path: FIXTURE, question: "Read page 1" }, opts);
return { result, renders, activities, usages };
}
function assertSuccess(loop: Loop, run: Awaited<ReturnType<typeof runLoop>>) {
if ("svg" in run.result) {
assert.equal(run.result.svg, SVG, JSON.stringify(run.result));
assert.equal(run.result.notes, FINAL);
} else {
assert.equal(run.result.brief, FINAL);
assert.equal(run.result.path, FIXTURE);
assert.deepEqual(run.result.pagesRead, [1]);
assert.deepEqual(run.result.pagesConsulted, [1]);
}
assert.deepEqual(run.renders, [loop === "visual" ? "svg" : "pdf:1"]);
assert.equal(run.usages.length, 3, "one usage callback per successful stream round");
assert.ok(run.activities.some((line) => line.startsWith(loop === "visual" ? "write_svg:" : "pdf_info:")));
assert.ok(run.activities.some((line) => line.startsWith(loop === "visual" ? "render_svg:" : "pdf_render:")));
}
// All global/env cases in this file are deliberately serial.
describe("provider-neutral raw agent loops", { concurrency: false }, () => {
for (const loop of LOOPS) {
it(`${loop}: every actual stream request has NO credential override (recording faux engine)`, async () => {
await isolated({ OPENROUTER_API_KEY: OR_KEY, LH_PROVIDER_EVENTS: "1" }, async () => {
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
faux.setResponses([0, 1, 2, 0, 1, 2].map((round) => {
const calls = callsFor(loop, round);
return fauxAssistantMessage(calls.length
? calls.map((c, i) => fauxToolCall(c.name, c.args, { id: `call_${round}_${i}` }))
: [fauxText(FINAL)], { stopReason: calls.length ? "toolUse" : "stop" });
}));
const requests: { context: TranscriptContext; options: Record<string, unknown> }[] = [];
const signal = new AbortController().signal;
const engine: PiEngine = {
model: faux.getModel(), level: "medium",
streamFn: (model, context, options) => {
requests.push({ context, options: { ...options } });
return faux.streamSimple(model, context, options);
},
};
const run = await runLoop(loop, engine, signal);
assertSuccess(loop, run);
assert.equal(requests.length, 3);
const sessionId = requests[0]!.options.sessionId;
assert.match(String(sessionId), /^[0-9a-f-]{36}$/);
assert.notEqual(sessionId, "provider-neutral-offline");
for (const { options } of requests) {
assert.equal(options.sessionId, sessionId);
for (const key of ["apiKey", "getApiKey", "auth", "headers"]) {
assert.ok(!Object.hasOwn(options, key), `${loop} stream must not supply ${key}`);
}
assert.equal(options.reasoning, "medium");
assert.equal(options.signal, signal);
assert.equal(typeof options.onProviderStreamEvent, "function");
}
assert.match(JSON.stringify(requests[2]!.context), /image\/png/);
assert.match(JSON.stringify(requests[2]!.context), /toolResult/);
const second = await runLoop(loop, engine, signal);
assertSuccess(loop, second);
assert.equal(requests.length, 6);
const secondId = requests[3]!.options.sessionId;
assert.notEqual(secondId, sessionId, "a separate unsaved operation must not reuse a conversation");
for (const request of requests.slice(3)) assert.equal(request.options.sessionId, secondId);
});
});
it(`${loop}: abort still reaches the stream and retains the loop's failure semantics`, async () => {
await isolated({ OPENROUTER_API_KEY: OR_KEY }, async () => {
const faux = createFauxCore({});
faux.setResponses([fauxAssistantMessage([fauxText("must not be delivered")])]);
const controller = new AbortController();
controller.abort();
const run = await runLoop(loop, { model: faux.getModel(), streamFn: faux.streamSimple }, controller.signal);
const failure = "svg" in run.result ? run.result.notes : run.result.brief;
assert.match(failure ?? "", /crashed — aborted/);
assert.deepEqual(run.renders, []);
assert.equal(run.usages.length, 0);
});
});
}
for (const transport of TRANSPORTS) {
for (const loop of LOOPS) {
it(`${loop}: native ${transport.provider}/${transport.api} endpoint, payload and live per-request auth`, async () => {
await isolated({ OPENROUTER_API_KEY: OR_KEY, ...(transport.provider === "openrouter" ? {} : { [transport.envVar]: "dummy-selected-key-first" }) }, async () => {
const engine = piEngineForSpec({ provider: transport.provider, id: transport.id, thinkingLevel: "off" });
assert.equal(engine.model.api, transport.api);
const requests: RecordedRequest[] = [];
globalThis.fetch = closedFetch(transport, loop, requests);
const run = await runLoop(loop, engine);
assertSuccess(loop, run);
assert.equal(requests.length, 3);
for (const [round, request] of requests.entries()) {
assert.equal(request.headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
if (transport.provider === "opencode-go") {
assert.match(request.headers.get("x-opencode-session") ?? "", /^[0-9a-f-]{36}$/);
assert.equal(request.headers.get("x-opencode-session"), requests[0]!.headers.get("x-opencode-session"));
assert.equal(request.headers.get("x-opencode-client"), "learnharness");
}
const url = new URL(request.url);
assert.equal(url.origin, transport.origin);
assert.equal(url.pathname, transport.path);
assert.equal(request.method, "POST");
const expected = transport.provider === "openrouter" ? OR_KEY : round === 0 ? "dummy-selected-key-first" : "dummy-selected-key-rotated";
assert.equal(request.headers.get(transport.authHeader), transport.authHeader === "authorization" ? `Bearer ${expected}` : expected);
if (transport.provider !== "openrouter") assert.ok(!JSON.stringify({ ...request, headers: Object.fromEntries(request.headers) }).includes(OR_KEY));
assertPayload(transport, loop, round, request.body);
}
});
});
if (transport.provider !== "openrouter") {
it(`${loop}: ${transport.provider} missing selected key does not fall back to configured OpenRouter`, async () => {
await isolated({ OPENROUTER_API_KEY: OR_KEY }, async () => {
let fetches = 0;
globalThis.fetch = async () => { fetches++; throw new Error("CLOSED: no fetch allowed"); };
const engine = piEngineForSpec({ provider: transport.provider, id: transport.id, thinkingLevel: "off" });
const run = await runLoop(loop, engine);
const failure = "svg" in run.result ? run.result.notes : run.result.brief;
assert.match(failure ?? "", new RegExp(`Provider is not configured: ${transport.provider}`));
assert.equal(fetches, 0, "SDK Models guard rejects missing selected-provider auth before transport");
assert.equal(run.usages.length, 0);
assert.deepEqual(run.renders, []);
});
});
}
}
}
it("all AUTH_PROVIDER_KEYS resolve their own plain-key env values in a fresh in-memory Models collection", async () => {
// No production singleton, credential files, process.env, OAuth refresh,
// or model refresh: this is a broad resolver contract, not wire coverage.
await isolated({}, async () => {
const env = Object.fromEntries(AUTH_PROVIDER_KEYS.map(({ envVar }) => [envVar, `dummy-resolver-${envVar}`]));
const models = createModels({ authContext: { env: async (name) => env[name], fileExists: async () => false } });
for (const provider of builtinProviders()) models.setProvider(provider);
for (const { id, envVar } of AUTH_PROVIDER_KEYS) {
const resolved = await models.getAuth(id);
assert.ok(resolved, id);
assert.equal(resolved.source, envVar, id);
assert.equal(resolved.auth.apiKey, env[envVar], id);
}
// In particular, intentionally shared variables must serve BOTH rows.
for (const envVar of new Set(AUTH_PROVIDER_KEYS.map((p) => p.envVar))) {
const siblings = AUTH_PROVIDER_KEYS.filter((p) => p.envVar === envVar);
if (siblings.length < 2) continue;
for (const { id } of siblings) assert.equal((await models.getAuth(id))?.auth.apiKey, env[envVar], id);
}
delete env.OPENAI_API_KEY;
assert.equal(await models.getAuth("openai"), undefined, "OpenRouter cannot configure a different provider");
env.OPENAI_API_KEY = "dummy-resolver-openai-rotated";
assert.equal((await models.getAuth("openai"))?.auth.apiKey, env.OPENAI_API_KEY);
});
});
});
interface RecordedRequest {
url: string; method: string; headers: Headers; body: Record<string, unknown>;
}
function closedFetch(transport: Transport, loop: Loop, requests: RecordedRequest[]): typeof fetch {
return async (input, init) => {
const request = new Request(input, init);
const round = requests.length;
// Unexpected destinations/rounds fail closed; never call original fetch.
assert.equal(new URL(request.url).origin, transport.origin);
assert.ok(round < 3, "unexpected extra provider request");
requests.push({ url: request.url, method: request.method, headers: request.headers, body: JSON.parse(await request.text()) });
if (transport.provider !== "openrouter") process.env[transport.envVar] = "dummy-selected-key-rotated";
return sseResponse(transport, callsFor(loop, round), round);
};
}
function assertPayload(transport: Transport, loop: Loop, round: number, body: Record<string, unknown>) {
if (transport.api !== "google-generative-ai") assert.equal(body.model, transport.id);
// Native Responses models may declare tools in additional_tools input
// items rather than top-level tools; assert the SDK's complete payload.
const tools = JSON.stringify(body);
assert.match(tools, new RegExp(loop === "visual" ? "write_svg" : "pdf_extract"));
assert.match(tools, new RegExp(loop === "visual" ? "render_svg" : "pdf_render"));
if (transport.api === "google-generative-ai") assert.ok(Array.isArray(body.contents));
else if (transport.api === "openai-responses") assert.ok(Array.isArray(body.input));
else assert.ok(Array.isArray(body.messages));
const json = JSON.stringify(body);
assert.match(json, new RegExp(loop === "visual" ? "white square" : "Read page 1"));
if (round === 2) {
const supportsImages = piEngineForSpec({ provider: transport.provider, id: transport.id }).model.input.includes("image");
assert.equal(json.includes(PNG.toString("base64")), supportsImages, "native modality filtering: only vision models receive rendered images");
assert.match(json, new RegExp(transport.api === "google-generative-ai" ? "functionResponse" : transport.api === "openai-responses" ? "function_call_output" : transport.api === "anthropic-messages" ? "tool_result" : '"role":"tool"'));
assert.match(json, new RegExp(loop === "visual" ? "Rendered" : "Page 1"));
}
}
function sseResponse(transport: Transport, calls: Call[], round: number): Response {
const events: Record<string, unknown>[] = [];
if (transport.api === "openai-completions") {
events.push({ id: `chatcmpl_offline_${round}`, object: "chat.completion.chunk", created: 1, model: transport.id,
choices: [{ index: 0, delta: calls.length ? { role: "assistant", tool_calls: calls.map((c, i) => ({ index: i, id: `call_${round}_${i}`, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args) } })) } : { role: "assistant", content: FINAL }, finish_reason: null }] });
events.push({ choices: [{ index: 0, delta: {}, finish_reason: calls.length ? "tool_calls" : "stop" }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } });
} else if (transport.api === "openai-responses") {
const output = calls.length ? calls.map((c, i) => ({ type: "function_call", id: `fc_${round}_${i}`, call_id: `call_${round}_${i}`, name: c.name, arguments: JSON.stringify(c.args), status: "completed" }))
: [{ type: "message", id: `msg_${round}`, role: "assistant", status: "completed", content: [{ type: "output_text", text: FINAL, annotations: [] }] }];
events.push({ type: "response.created", response: { id: `resp_offline_${round}`, status: "in_progress" } });
for (const [index, item] of output.entries()) {
events.push({ type: "response.output_item.added", output_index: index, item: { ...item, status: "in_progress", ...(calls.length ? { arguments: "" } : { content: [] }) } });
events.push(calls.length
? { type: "response.function_call_arguments.delta", output_index: index, delta: JSON.stringify(calls[index]!.args) }
: { type: "response.output_text.delta", output_index: index, content_index: 0, delta: FINAL });
events.push({ type: "response.output_item.done", output_index: index, item });
}
events.push({ type: "response.completed", response: { id: `resp_offline_${round}`, status: "completed", output, usage: { input_tokens: 20, output_tokens: 10, total_tokens: 30 } } });
} else if (transport.api === "anthropic-messages") {
events.push({ type: "message_start", message: { id: `msg_offline_${round}`, type: "message", role: "assistant", model: transport.id, content: [], stop_reason: null, usage: { input_tokens: 20, output_tokens: 0 } } });
const blocks = calls.length ? calls.map((c, i) => ({ type: "tool_use", id: `call_${round}_${i}`, name: c.name, input: {} })) : [{ type: "text", text: "" }];
for (const [index, block] of blocks.entries()) {
events.push({ type: "content_block_start", index, content_block: block });
events.push({ type: "content_block_delta", index, delta: calls.length ? { type: "input_json_delta", partial_json: JSON.stringify(calls[index]!.args) } : { type: "text_delta", text: FINAL } });
events.push({ type: "content_block_stop", index });
}
events.push({ type: "message_delta", delta: { stop_reason: calls.length ? "tool_use" : "end_turn" }, usage: { output_tokens: 10 } });
events.push({ type: "message_stop" });
} else {
events.push({ responseId: `google_offline_${round}`, candidates: [{ index: 0, content: { role: "model", parts: calls.length ? calls.map((c, i) => ({ functionCall: { id: `call_${round}_${i}`, name: c.name, args: c.args } })) : [{ text: FINAL }] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 10, totalTokenCount: 30 } });
}
const data = events.map((event) => `${typeof event.type === "string" ? `event: ${event.type}\n` : ""}data: ${JSON.stringify(event)}\n\n`).join("") + (transport.api === "openai-completions" ? "data: [DONE]\n\n" : "");
return new Response(data, { status: 200, headers: { "content-type": "text/event-stream" } });
}
+19 -2
View File
@@ -58,7 +58,8 @@ describe("SessionRow", () => {
const html = render(item({ phase: "teach", pendingQuestion: true }));
assert.ok(!html.includes("session-dot"), "no dots");
assert.ok(!html.includes("braille-spinner"), "motion is reserved for working");
assert.ok(html.includes("Waiting for your answer"), "the meta line's text");
assert.ok(html.includes("Waiting for input"), "the meta line's text");
assert.ok(!html.includes("Waiting for your answer"), "old wording is gone");
});
it("a working session (turn in flight) renders the braille spinner — the ONLY indicator", () => {
@@ -82,7 +83,7 @@ describe("SessionRow", () => {
it("the meta line: state replaces the count when the state is the useful fact", () => {
const awaiting = render(item({ pendingQuestion: true, updatedAt: "2026-01-02T01:00:00.000Z" }));
assert.ok(awaiting.includes("Waiting for your answer · "), "awaiting meta");
assert.ok(awaiting.includes("Waiting for input · 2026-01-02"), "awaiting meta keeps the existing date");
assert.ok(!awaiting.includes("messages"), "count yields to the state");
assert.ok(!awaiting.includes("braille-spinner"), "…and the spinner stays off");
@@ -97,6 +98,22 @@ describe("SessionRow", () => {
assert.match(idle, /\d{4}-\d{2}-\d{2}/, "idle shows the ISO date");
});
it("pending questions use the same wording for teach and practice with recent and old dates", () => {
for (const kind of ["teach", "practice"] as const) {
for (const updatedAt of [new Date().toISOString(), "2025-06-02T12:00:00.000Z"]) {
const html = render(item({ kind, pendingQuestion: true, updatedAt }));
assert.ok(html.includes(`Waiting for input · ${timeAgo(updatedAt)}`));
assert.ok(!html.includes("Waiting for your answer"));
}
}
});
it("compacting retains priority over pending input", () => {
const html = render(item({ compacting: true, pendingQuestion: true }));
assert.match(html, /Compacting context ·/);
assert.doesNotMatch(html, /Waiting for input/);
});
it("active row is visually marked by the background alone (owner, round 5: no weight change)", () => {
const html = render(item({}), true);
assert.ok(html.includes("bg-accent"), "active background");
+252
View File
@@ -0,0 +1,252 @@
/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
import React 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 { parseHTML } from "linkedom";
import { validateToolArguments } from "@earendil-works/pi-ai";
import { problemInputObject } from "../src/agent/tools/practice.ts";
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 {
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
toLlmMessages, type LhMessage, type ArtifactPartProps,
} from "../src/core/index.ts";
const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`;
const clean = `$${tex}$`;
const doubled = clean.replace(/\\/g, "\\\\");
const quadrupled = clean.replace(/\\/g, "\\\\\\\\");
const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f.
const repairedDebris = String.raw`\forall x \in \mathbb{R}`;
const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$";
const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`;
const part = (input: unknown, over: Partial<ArtifactPartProps["part"]> = {}): ArtifactPartProps["part"] => ({
type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
});
const dock = (input: { statement: string }, expanded = true) => (
<PracticeDock problemCallId="call_math" input={input} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
);
function math(html: string) {
const { document } = parseHTML(html);
return {
annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent),
glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""),
errors: document.querySelectorAll(".katex-error").length,
};
}
function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) {
const actual = math(html);
assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors");
assert.equal(actual.errors, 0);
for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`);
}
describe("repairMathForDisplay — the existing composition, unchanged", () => {
const unchanged = [
clean,
String.raw`outside \\nabla`,
String.raw`$a \\\nabla b$`,
String.raw`$a \\\\\nabla b$`,
String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`,
"$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too.
"`" + doubled + "`",
"```tex\n" + doubled + "\n```",
"`" + debris + "`",
"```tex\n" + debris + "\n```",
String.raw`$\\nabla`,
"$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close.
];
for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix,
String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) {
it(`matches the old composition byte-for-byte (${index})`, () => {
assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value)));
});
}
it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => {
for (const value of unchanged) assert.equal(repairMathForDisplay(value), value);
assert.equal(repairMathForDisplay(doubled), clean);
assert.equal(repairMathForDisplay(quadrupled), clean);
assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`);
assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`);
});
});
describe("upgraded KaTeX rendering on real question and practice surfaces", () => {
for (const [name, formula, glyphs] of [
["fraction and root", String.raw`\frac{1}{\sqrt{x}}`, ["1", "x"]],
["accents", String.raw`\hat{x}+\bar{y}`, ["x", "y"]],
["sum", String.raw`\sum_{i=1}^{n}i`, ["∑", "i", "n"]],
["matrix rows", String.raw`\begin{pmatrix}1&2\\3&4\end{pmatrix}`, ["1", "2", "3", "4"]],
["aligned rows", String.raw`\begin{aligned}a&=b\\ c&=d\end{aligned}`, ["a", "b", "c", "d", "="]],
] as const) {
it(`${name}: dollar and legacy delimiters preserve glyphs and copied TeX`, () => {
for (const raw of [`$${formula}$`, `\\(${formula}\\)`, `$$\n${formula}\n$$`, `\\[\n${formula}\n\\]`]) {
for (const element of [
<MessageResponse mode="static">{raw}</MessageResponse>,
<QuestionDock toolName="ask_user_question" toolCallId="security_question" input={{ question: raw }} expanded onExpand={() => {}} onSubmit={() => {}} />,
dock({ statement: raw }),
]) {
const html = renderToStaticMarkup(element);
assertMath(html, [formula], [...glyphs]);
const { document } = parseHTML(html);
assert.ok(document.querySelector(".katex-base .katex-strut"), "matching 0.18 internal layout classes");
if (name === "fraction and root") assert.ok(document.querySelector(".mfrac .sqrt"));
if (name === "accents") assert.ok(document.querySelectorAll(".katex-accent").length >= 2);
if (name.endsWith("rows")) assert.ok(document.querySelectorAll(".mtable .mtr").length >= 2 || document.querySelectorAll(".mtable .vlist > span").length >= 2, "two real rows, not repaired-away slashes");
}
}
});
}
it("preserves escaped currency and code on both dock surfaces", () => {
const raw = "Costs \\$5 and \\$10. `" + clean + "`\n\n```text\n" + doubled + "\n```";
for (const element of [dock({ statement: raw }), <QuestionDock toolName="ask_user_question" toolCallId="security_code" input={{ question: raw }} expanded onExpand={() => {}} onSubmit={() => {}} />]) {
const html = renderToStaticMarkup(element);
assert.deepEqual(math(html).annotations, []);
assert.ok(parseHTML(html).document.firstElementChild?.textContent?.includes("$5 and $10"));
assert.ok(parseHTML(html).document.querySelector("code")?.textContent?.includes(clean));
}
});
it("renders direct LatexGlyph connectors through the upgraded root", () => {
for (const [command, glyph] of [[String.raw`\Rightarrow`, "⇒"], [String.raw`\land`, "∧"], [String.raw`\neg`, "¬"]]) {
const html = renderToStaticMarkup(<LatexGlyph cmd={command!} />);
const { document } = parseHTML(html);
assert.equal(document.querySelectorAll(".katex-error").length, 0);
assert.ok(document.querySelector(".katex-base .katex-strut"));
assert.ok(document.querySelector(".katex-html")?.textContent?.includes(glyph!));
}
});
});
describe("practice display surfaces", () => {
for (const [name, raw, expected, glyphs] of [
["two command slashes", doubled, tex, ["∇", "∈", "×"]],
["four command slashes", quadrupled, tex, ["∇", "∈", "×"]],
["JSON debris", debris, repairedDebris, ["∀", "∈"]],
["clean math", clean, tex, ["∇", "∈", "×"]],
["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]],
] as const) {
it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => {
const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` });
const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw });
const before = JSON.stringify({ input, conclusion });
for (const element of [dock(input), <ProblemCard part={part(input)} />]) {
const html = renderToStaticMarkup(element);
assertMath(html, [expected], [...glyphs]);
assert.ok(!html.includes("HIDDEN COMMITMENT"));
}
assertMath(renderToStaticMarkup(<ConcludeCard part={part(conclusion, { type: "tool-conclude", reveal: raw })} />),
[expected, expected], [...glyphs]);
assertMath(renderToStaticMarkup(<ConcludeCard part={part({ outcome: "solved", solutionReveal: raw })} />),
[expected], [...glyphs]);
assert.equal(JSON.stringify({ input, conclusion }), before);
});
}
it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => {
const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." };
const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw };
const validated = validateToolArguments(
{ name: "problem", parameters: problemInputObject } as never,
call as never,
) as { statement: unknown };
assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments");
assert.equal(raw.statement, 17, "saved arguments retain the number");
const messages = [
{ id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 },
{ id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
] as unknown as LhMessage[];
const before = structuredClone(messages);
const active = activeProblemOf(messages);
assert.ok(active);
assert.equal((active.input as { statement: unknown }).statement, 17);
// Mirror the existing runtime cast at the Chat -> PracticeDock seam.
const input = active.input as { statement: string };
let expanded = "";
assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); });
const { document } = parseHTML(expanded);
assert.equal(document.querySelector(".lh-prose")!.innerHTML,
parseHTML(renderToStaticMarkup(<MessageResponse>{input.statement}</MessageResponse>)).document.firstElementChild!.outerHTML,
"non-string values pass through exactly as before, without string coercion or a new fallback");
const collapsed = renderToStaticMarkup(dock(input, false));
assert.match(collapsed, /data-testid="practice-dock-bar"/);
assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17");
assert.deepEqual(math(collapsed).annotations, []);
assert.deepEqual(messages, before);
assert.equal(raw.statement, 17);
});
it("preserves code spans/fences across every repaired display surface", () => {
const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```";
for (const element of [dock({ statement: raw }), <ProblemCard part={part({ statement: raw })} />,
<ConcludeCard part={part({ outcome: "solved", verdict: raw }, { reveal: raw })} />]) {
const html = renderToStaticMarkup(element);
assert.deepEqual(math(html).annotations, []);
const { document } = parseHTML(html);
assert.ok(document.querySelector("code")?.textContent?.includes(doubled));
}
});
it("does not change the collapsed preview, dismissal or global prose renderer", () => {
const preview = renderToStaticMarkup(dock({ statement: doubled }, false));
assert.ok(preview.includes(doubled));
assert.deepEqual(math(preview).annotations, []);
const dismissed = renderToStaticMarkup(<ConcludeCard part={part({ outcome: "dismissed", verdict: doubled }, { reveal: doubled })} />);
assert.ok(dismissed.includes("Problem dismissed."));
assert.deepEqual(math(dismissed).annotations, []);
const plain = math(renderToStaticMarkup(<MessageResponse>{doubled}</MessageResponse>));
assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]);
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", () => {
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.deepEqual({ quiz, ask, explain }, before);
});
it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => {
const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" };
const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` };
const messages = [
{ ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] },
{ id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
] as unknown as LhMessage[];
const concluded = [...messages,
{ ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] },
{ id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 },
] as unknown as LhMessage[];
const before = structuredClone(concluded);
const history = structuredClone(toLlmMessages(concluded));
assert.deepEqual(activeProblemOf(messages)?.input, input);
const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!;
assert.ok(problem.state && problem.toolCallId);
assert.deepEqual(problem.input, input);
const statementHtml = renderToStaticMarkup(<ProblemCard part={{ ...problem, state: problem.state, toolCallId: problem.toolCallId }} />);
assertMath(statementHtml);
assert.ok(!statementHtml.includes("COMMITTED"));
const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!;
assert.ok(conclusion.state && conclusion.toolCallId);
assert.equal(conclusion.reveal, input.modelSolution);
const revealHtml = renderToStaticMarkup(<ConcludeCard part={{ ...conclusion, state: conclusion.state, toolCallId: conclusion.toolCallId }} />);
assert.ok(revealHtml.includes("COMMITTED"));
assertMath(revealHtml, [tex, tex]);
assert.deepEqual(concluded, before);
assert.deepEqual(toLlmMessages(concluded), history);
});
});
+192
View File
@@ -0,0 +1,192 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Type } from "typebox";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, normalizeContext, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai";
import { streamSimple as streamCodex } from "@earendil-works/pi-ai/api/openai-codex-responses";
import { APP_IDENTITY, piEngineForSpec, type PiEngine } from "../src/agent/models.ts";
import { createProberAgent, createTeacherAgent, createPracticeAgent, type AgentBuildOptions } from "../src/agent/teacher-agent.ts";
import { runSubagent } from "../src/agent/subagents/pi-run.ts";
import { healToolRecord } from "../src/agent/tools/heal.ts";
import metadata from "../package.json" with { type: "json" };
const factories = [
(opts: AgentBuildOptions) => createProberAgent({ chatId: opts.chatId, searchAvailable: () => false } as never, opts),
(opts: AgentBuildOptions) => createTeacherAgent(undefined, opts),
(opts: AgentBuildOptions) => createPracticeAgent({} as never, opts),
];
function recordingEngine() {
const faux = createFauxCore({});
faux.setResponses([fauxAssistantMessage([fauxToolCall("again", {}, { id: "call_0" })], { stopReason: "toolUse" }), { ...fauxAssistantMessage([fauxText("done")]), durationMs: 42 }]);
const requests: SimpleStreamOptions[] = [];
const engine: PiEngine = { model: faux.getModel(), streamFn: (model, context, options) => {
requests.push(options ?? {});
return faux.streamSimple(model, context, options);
} };
return { engine, requests };
}
const again = { name: "again", label: "again", description: "Continue", parameters: Type.Object({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }], details: {} }) };
// Whole-process replacement isolates native auth/profile discovery; fetch never
// delegates. No real credentials, .env, OAuth accounts, or model inference.
async function closed<T>(run: () => Promise<T>): Promise<T> {
const env = process.env;
const fetch = globalThis.fetch;
process.env = { NODE_ENV: "test", HOME: "/nonexistent/session-identity", OPENROUTER_API_KEY: "dummy-router", OPENCODE_API_KEY: "dummy-go", OPENAI_API_KEY: "dummy-openai" };
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected fetch"); };
try { return await run(); } finally { process.env = env; globalThis.fetch = fetch; }
}
describe("native app and conversation identity", { concurrency: false }, () => {
it("shares one deeply frozen metadata-derived identity, without mutable conversation state", () => {
assert.deepEqual(APP_IDENTITY, { userAgent: { custom: `learnharness/${metadata.version}` }, attribution: { name: metadata.name, url: metadata.homepage } });
for (const value of [APP_IDENTITY, APP_IDENTITY.userAgent, APP_IDENTITY.attribution]) assert.ok(Object.isFrozen(value));
assert.ok(!Object.hasOwn(APP_IDENTITY, "sessionId"));
});
it("reconstructed prober/teacher/practice Agents reuse the exact chat id across tool rounds", async () => {
for (const chatId of ["chat_exact_native_123", "chat_independent_456"]) {
for (const factory of factories) {
const { engine, requests } = recordingEngine();
const agent = factory({ engine, chatId, tools: { again } });
assert.equal(agent.identity, APP_IDENTITY);
assert.equal(agent.sessionId, chatId);
await agent.prompt("offline");
assert.equal(requests.length, 2);
for (const options of requests) {
assert.equal(options.sessionId, chatId);
assert.deepEqual(options.identity, APP_IDENTITY);
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.apiKey, undefined);
}
}
}
});
it("separate extractor/auditor/compaction invocations own distinct stable unsaved conversations", async () => {
const ids = new Set<unknown>();
// Compaction uses the shared runner with the researcher role.
for (const role of ["extractor", "auditor", "researcher"] as const) {
const { engine, requests } = recordingEngine();
let usage = 0;
const result = await runSubagent([again], { role, engine, chatId: "chat_shared_debug_only", systemPrompt: "offline", prompt: "offline", maxTurns: 3, onUsage: () => usage++ });
assert.equal(requests.length, 2);
assert.equal(usage, 2);
const id = requests[0]!.sessionId;
assert.match(id ?? "", /^[0-9a-f-]{36}$/);
assert.notEqual(id, "chat_shared_debug_only");
assert.ok(!ids.has(id));
ids.add(id);
for (const options of requests) { assert.equal(options.sessionId, id); assert.deepEqual(options.identity, APP_IDENTITY); }
assert.ok(result.newMessages.some((m) => m.role === "toolResult" && typeof m.durationMs === "number"), "new native tool duration fields are preserved");
assert.ok(result.newMessages.some((m) => m.role === "assistant" && typeof m.durationMs === "number"), "new native assistant duration fields are preserved");
}
});
for (const [provider, id, endpoint] of [
["opencode-go", "claude-haiku-5-5", "/inference/go/v1/messages"],
["opencode-go", "gemini-4-argon-contributor", "/inference/go/v1/models/gemini-4-argon-contributor:streamGenerateContent"],
["opencode-go", "glm-5.3", "/inference/go/v1/chat/completions"],
["opencode-go", "gpt-6-luna", "/inference/go/v1/responses"],
["openrouter", "openrouter/free", "/api/v1/chat/completions"],
] as const) {
it(`complete native ${provider}/${id} foreground serialization carries app identity and exact sessions`, async () => closed(async () => {
const engine = piEngineForSpec({ provider, id, thinkingLevel: "off" });
const requests: Request[] = [];
globalThis.fetch = async (input, init) => { const request = new Request(input, init); requests.push(request); await request.text(); return new Response('{"error":{"message":"closed fixture"}}', { status: 400, headers: { "content-type": "application/json" } }); };
for (const factory of factories) await factory({ engine, chatId: "chat_wire_exact", tools: {} }).prompt("offline");
assert.equal(requests.length, 3);
for (const request of requests) {
assert.equal(new URL(request.url).pathname, endpoint);
assert.equal(request.headers.get("user-agent"), `learnharness/${metadata.version}`);
if (provider === "opencode-go") {
assert.equal(new URL(request.url).origin, "https://opencode.ai");
assert.equal(request.headers.get("x-opencode-client"), metadata.name);
assert.equal(request.headers.get("x-opencode-session"), "chat_wire_exact");
assert.ok(!JSON.stringify(Object.fromEntries(request.headers)).includes("dummy-router"));
} else {
assert.equal(new URL(request.url).origin, "https://openrouter.ai");
assert.equal(request.headers.get("x-openrouter-title"), metadata.name);
assert.equal(request.headers.get("http-referer"), metadata.homepage);
assert.equal(request.headers.get("x-session-id"), "chat_wire_exact");
}
}
}));
}
it("native OpenAI cache keys clamp to 64 characters while affinity retains exact session", async () => closed(async () => {
const engine = piEngineForSpec({ provider: "openai", id: "gpt-4o-mini", thinkingLevel: "off" });
const sessionId = "chat_" + "a".repeat(70);
let body: Record<string, unknown> = {};
let headers = new Headers();
globalThis.fetch = async (input, init) => { const request = new Request(input, init); headers = request.headers; body = JSON.parse(await request.text()); return new Response("{}", { status: 400, headers: { "content-type": "application/json" } }); };
await createTeacherAgent(undefined, { engine, chatId: sessionId, tools: {} }).prompt("offline");
assert.equal(body.prompt_cache_key, Array.from(sessionId).slice(0, 64).join(""));
assert.equal(headers.get("session_id"), sessionId);
}));
it("native OpenAI cache-key clamp counts Unicode code points, not UTF-16 units", async () => closed(async () => {
const engine = piEngineForSpec({ provider: "openai", id: "gpt-4o-mini", thinkingLevel: "off" });
const sessionId = "chat_" + "😀".repeat(70);
let payload: unknown;
// HTTP affinity headers cannot encode emoji. Observe native payload
// serialization before transport, then stop deliberately (no accounts).
await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }] }), {
sessionId,
onPayload: (value) => { payload = value; throw new Error("CLOSED: payload captured"); },
}).result();
assert.equal((payload as { prompt_cache_key: string }).prompt_cache_key, Array.from(sessionId).slice(0, 64).join(""));
}));
it("native Codex serialization shares the 64-code-point clamp without an account or OAuth flow", async () => closed(async () => {
const engine = piEngineForSpec({ provider: "openai-codex", id: "gpt-6-luna", thinkingLevel: "off" });
// Synthetic, unsigned fixture token solely satisfies native local claim
// decoding. Stop at payload capture: no account, OAuth, or fetch.
const claims = { "https://api.openai.com/auth": { chatgpt_account_id: "offline-fixture" } };
const apiKey = `dummy.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.dummy`;
for (const sessionId of ["chat_" + "a".repeat(70), "chat_" + "😀".repeat(70)]) {
let payload: unknown;
// The app intentionally has no Codex auth row: exercise the native
// serializer directly rather than adding an OAuth configuration path.
assert.equal(engine.model.api, "openai-codex-responses");
const result = await streamCodex(engine.model as Model<"openai-codex-responses">, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }] }), {
apiKey, sessionId, identity: APP_IDENTITY,
onPayload: (value) => { payload = value; throw new Error("CLOSED: payload captured"); },
}).result();
assert.ok(payload, result.errorMessage);
assert.equal((payload as { prompt_cache_key: string }).prompt_cache_key, Array.from(sessionId).slice(0, 64).join(""));
}
}));
it("SDK native header precedence and cache-off behavior remain untouched", async () => closed(async () => {
const engine = piEngineForSpec({ provider: "opencode-go", id: "gpt-6-luna", thinkingLevel: "off" });
let headers = new Headers();
let body: Record<string, unknown> = {};
globalThis.fetch = async (input, init) => { const request = new Request(input, init); headers = request.headers; body = JSON.parse(await request.text()); return new Response("{}", { status: 400, headers: { "content-type": "application/json" } }); };
await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }] }), { sessionId: "chat_cache_off", cacheRetention: "none", headers: { "user-agent": "explicit-sdk-override", "x-opencode-client": null } }).result();
assert.equal(headers.get("user-agent"), "explicit-sdk-override");
assert.equal(headers.get("x-opencode-client"), null);
// Native OpenCode routing identity remains even with cache retention off.
assert.equal(headers.get("x-opencode-session"), "chat_cache_off");
assert.equal(body.prompt_cache_key, undefined);
}));
it("completed malformed native JSON stays rejected despite app prepareArguments healing", async () => closed(async () => {
const engine = piEngineForSpec({ provider: "openrouter", id: "openai/gpt-4o-mini", thinkingLevel: "off" });
const malformed = '{"value":';
globalThis.fetch = async () => new Response(`data: ${JSON.stringify({ id: "offline", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_bad", type: "function", function: { name: "check", arguments: malformed } }] }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } });
let executed = 0;
let prepared = 0;
const tools = healToolRecord({ check: { name: "check", label: "check", description: "check", parameters: Type.Object({ value: Type.Optional(Type.String()) }), execute: async () => { executed++; return { content: [], details: {} }; } } });
const heal = tools.check!.prepareArguments!;
tools.check!.prepareArguments = (args) => { prepared++; return heal(args); };
const result = await runSubagent(Object.values(tools), { role: "extractor", engine, systemPrompt: "offline", prompt: "offline", maxTurns: 1 });
const assistant = result.newMessages.find((m) => m.role === "assistant");
assert.ok(assistant?.role === "assistant");
const call = assistant.content.find((c) => c.type === "toolCall");
assert.ok(call?.type === "toolCall" && call.argumentsError);
assert.equal(executed, 0);
assert.equal(prepared, 1, "SDK prepares arguments but preserves argumentsError for native validation");
assert.ok(result.newMessages.some((m) => m.role === "toolResult" && m.isError));
}));
});
+3 -3
View File
@@ -51,11 +51,11 @@ test("callCost: glm-5.3-flash math — fresh input, cached read, output", () =>
test("callCost: cache writes bill at the cacheWrite rate", () => {
const cost = callCost(
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
"openrouter:qwen/qwen3.8-max-0902",
"anthropic:claude-haiku-4-5",
);
assert.ok(cost !== null);
// fresh 1M @ $2/M + cache write 500k @ $2.5/M
assert.ok(Math.abs(cost! - (2 + 1.25)) < 1e-9);
// Native 1.4 catalog: fresh 1M @ $1/M + cache write 500k @ $1.25/M.
assert.ok(Math.abs(cost! - (1 + 0.625)) < 1e-9);
});
test("callCost: unknown model and absent fields are null, never zero", () => {