Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
388addff3e | ||
|
|
c36c1c6b4d | ||
|
|
a67366005a | ||
|
|
2d67376b76 | ||
|
|
aef01c44a0 | ||
|
|
261a9b9927 |
@@ -13,7 +13,7 @@
|
|||||||
* healthy. The dock's chips + graph refetch track progress live through
|
* healthy. The dock's chips + graph refetch track progress live through
|
||||||
* /api/graph/status, as with session runs.
|
* /api/graph/status, as with session runs.
|
||||||
*/
|
*/
|
||||||
import { extraction } from "@/src/server";
|
import { extraction, NotesImportError } from "@/src/server";
|
||||||
import {
|
import {
|
||||||
ModelsNotConfiguredError,
|
ModelsNotConfiguredError,
|
||||||
PROVIDER_KEY_MISSING_CODE,
|
PROVIDER_KEY_MISSING_CODE,
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
interface ImportResult {
|
interface ImportResult {
|
||||||
|
requestIndex: number;
|
||||||
|
outcome: "completed" | "failed" | "partial" | "aborted" | "busy";
|
||||||
filePath: string;
|
filePath: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
stats?: EmissionStats;
|
stats?: EmissionStats;
|
||||||
@@ -46,20 +48,22 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results: ImportResult[] = [];
|
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 () => {
|
const workers = Array.from({ length: Math.min(3, files.length) }, async () => {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const file = queue.shift();
|
const next = queue.shift();
|
||||||
if (!file) return;
|
if (!next) return;
|
||||||
|
const { file, requestIndex } = next;
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const stats = await extraction.importNotes({ filePath: file.name, 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) {
|
} catch (err) {
|
||||||
if (err instanceof ModelsNotConfiguredError) {
|
if (err instanceof ModelsNotConfiguredError) {
|
||||||
results.push({
|
results.push({
|
||||||
filePath: file.name,
|
filePath: file.name,
|
||||||
ok: false,
|
ok: false,
|
||||||
|
requestIndex, outcome: "failed",
|
||||||
error: err.envelope.error,
|
error: err.envelope.error,
|
||||||
code: "models_not_configured",
|
code: "models_not_configured",
|
||||||
missing: err.envelope.missing,
|
missing: err.envelope.missing,
|
||||||
@@ -70,6 +74,7 @@ export async function POST(request: Request) {
|
|||||||
results.push({
|
results.push({
|
||||||
filePath: file.name,
|
filePath: file.name,
|
||||||
ok: false,
|
ok: false,
|
||||||
|
requestIndex, outcome: "failed",
|
||||||
error: err.envelope.error,
|
error: err.envelope.error,
|
||||||
code: PROVIDER_KEY_MISSING_CODE,
|
code: PROVIDER_KEY_MISSING_CODE,
|
||||||
provider: err.envelope.provider,
|
provider: err.envelope.provider,
|
||||||
@@ -77,7 +82,12 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
continue;
|
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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
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
|
// /api/graph/import; the runs surface in the status chips below, and the
|
||||||
// graph refetches when they finish (the runs-empty transition).
|
// graph refetches when they finish (the runs-empty transition).
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
// The dock's start-site nudge (accuracy wave, 2026-09-19): the import
|
// 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
|
// 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
|
// 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
|
// start; from its first setPhase the run announces itself on the events
|
||||||
// channel (S5), and the dock rides those announcements thereafter.
|
// channel (S5), and the dock rides those announcements thereafter.
|
||||||
const refreshActivity = useActivity().refresh;
|
const refreshActivity = useActivity().refresh;
|
||||||
const onImportFiles = (files: FileList | null) => {
|
const notesImport = useNotesImport({
|
||||||
if (!files || files.length === 0) return;
|
onStart: () => {
|
||||||
setImporting(true);
|
|
||||||
refreshActivity();
|
refreshActivity();
|
||||||
setTimeout(() => refreshActivity(), 600);
|
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 })
|
const { importing, onImportFiles } = notesImport;
|
||||||
.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));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Background pipeline status: read /api/graph/status; whenever a run
|
// Background pipeline status: read /api/graph/status; whenever a run
|
||||||
// LEAVES the feed (finished / aborted / failed), refetch the graph.
|
// LEAVES the feed (finished / aborted / failed), refetch the graph.
|
||||||
@@ -1614,6 +1605,7 @@ export function GraphView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotesImportErrors errors={notesImport.errors} />
|
||||||
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
|
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
|
||||||
just a number, hover-highlighted, dropdown breakdown — then
|
just a number, hover-highlighted, dropdown breakdown — then
|
||||||
the running-pipeline pills under it. Lives BELOW the actions
|
the running-pipeline pills under it. Lives BELOW the actions
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { ListTreeIcon, UploadIcon } from "lucide-react";
|
import { ListTreeIcon, UploadIcon } from "lucide-react";
|
||||||
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
||||||
import { GraphCostControl } from "@/components/graph-view.tsx";
|
import { GraphCostControl } from "@/components/graph-view.tsx";
|
||||||
|
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
|
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
|
||||||
|
|
||||||
@@ -96,7 +97,6 @@ export function MinimalHome({
|
|||||||
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
|
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
|
||||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
const [chainsOpen, setChainsOpen] = useState(false);
|
const [chainsOpen, setChainsOpen] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const readState = useCallback(() => {
|
const readState = useCallback(() => {
|
||||||
@@ -123,25 +123,9 @@ export function MinimalHome({
|
|||||||
if (chainSelectOpen) setChainsOpen(true);
|
if (chainSelectOpen) setChainsOpen(true);
|
||||||
}, [chainSelectOpen]);
|
}, [chainSelectOpen]);
|
||||||
|
|
||||||
// Notes import — the same front door as the graph home: POST awaits
|
// Refetch even after incomplete imports: earlier writes and spend remain.
|
||||||
// the whole pipeline server-side, then one refetch lands the numbers.
|
const notesImport = useNotesImport({ onFinish: readState });
|
||||||
const onImportFiles = (files: FileList | null) => {
|
const { importing, onImportFiles } = notesImport;
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const teachSessions = chats.filter((c) => c.kind === "teach").length;
|
const teachSessions = chats.filter((c) => c.kind === "teach").length;
|
||||||
const practiceSessions = chats.filter((c) => c.kind === "practice").length;
|
const practiceSessions = chats.filter((c) => c.kind === "practice").length;
|
||||||
@@ -210,6 +194,7 @@ export function MinimalHome({
|
|||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<NotesImportErrors errors={notesImport.errors} />
|
||||||
{costs && costs.runs.length > 0 && <GraphCostControl costs={costs} />}
|
{costs && costs.runs.length > 0 && <GraphCostControl costs={costs} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))}</>;
|
||||||
|
}
|
||||||
@@ -213,7 +213,7 @@ export function SidebarToggle() {
|
|||||||
* phases only advance (all sessions end in Teaching — phase-as-signal was
|
* phases only advance (all sessions end in Teaching — phase-as-signal was
|
||||||
* decoration, now retired app-wide), and question-pending is the dominant
|
* decoration, now retired app-wide), and question-pending is the dominant
|
||||||
* RESTING state of real sessions — a resting state has no scan value, so
|
* 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.
|
* absence of a signal, by design.
|
||||||
*/
|
*/
|
||||||
function SessionStatus({ busy }: { busy: boolean }) {
|
function SessionStatus({ busy }: { busy: boolean }) {
|
||||||
@@ -250,7 +250,7 @@ export function timeAgo(iso: string, now = new Date()): string {
|
|||||||
* ever wanted. */
|
* ever wanted. */
|
||||||
function sessionMeta(chat: ChatListItem): string {
|
function sessionMeta(chat: ChatListItem): string {
|
||||||
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
|
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) {
|
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
|
||||||
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
|
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.0.2",
|
"@earendil-works/pi-agent-core": "1.0.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Amos Blomqvist",
|
"name": "Amos Blomqvist",
|
||||||
|
|||||||
@@ -742,6 +742,20 @@ export interface NotesImportInput {
|
|||||||
text: string;
|
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 {
|
export interface ExtractionPipeline {
|
||||||
/** Extract + audit + merge ONE chat. Single-flight per chat; resolves to
|
/** Extract + audit + merge ONE chat. Single-flight per chat; resolves to
|
||||||
* the merger's append stats (or throws — no checkpoint on failure). */
|
* 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> {
|
async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> {
|
||||||
const { id } = input;
|
const { id } = input;
|
||||||
if (inFlight.has(id)) return { nodes: 0, chains: 0 };
|
// importNotes owns the source exclusion, including metadata and cleanup.
|
||||||
inFlight.add(id);
|
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
|
const mergedStats: EmissionStats = { nodes: 0, chains: 0 };
|
||||||
try {
|
try {
|
||||||
// BYOK per-need gate (owner, 2026-09-22) + key presence
|
// BYOK per-need gate (owner, 2026-09-22) + key presence
|
||||||
// (2026-09-25): same refusal as the chat front door — no
|
// (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>[] = [];
|
const settled: PromiseSettledResult<EmissionStats>[] = [];
|
||||||
for (const [chunkIndex, chunk] of chunks.entries()) {
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
||||||
const part = chunks.length > 1 ? { i: chunkIndex + 1, n: chunks.length } : undefined;
|
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 {
|
try {
|
||||||
settled.push({
|
settled.push({
|
||||||
status: "fulfilled",
|
status: "fulfilled",
|
||||||
value: await runJob({ id, kind: "notes", label: input.filePath, part }, async (jobKey) => {
|
value: await runJob({ id, kind: "notes", label: input.filePath, part }, async (jobKey) => {
|
||||||
setPhase(jobKey, "queued", "notes", input.filePath, part);
|
setPhase(jobKey, "queued", "notes", input.filePath, part);
|
||||||
|
chunkSignal = runningExtractions.get(jobKey)?.controller.signal;
|
||||||
const findings = await withExtractSlot(jobKey, async () => {
|
const findings = await withExtractSlot(jobKey, async () => {
|
||||||
setPhase(jobKey, "extracting", "notes", input.filePath, part);
|
setPhase(jobKey, "extracting", "notes", input.filePath, part);
|
||||||
return notesExtract(jobKey, { ...input, text: chunk, part });
|
return notesExtract(jobKey, { ...input, text: chunk, part });
|
||||||
@@ -1337,6 +1355,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
{ chatStore, graphStore },
|
{ chatStore, graphStore },
|
||||||
{ foldedAt: input.importedAt },
|
{ 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
|
// The layout ledger evolves with each merge — the next
|
||||||
// snapshot fetch reflects every finished chunk.
|
// snapshot fetch reflects every finished chunk.
|
||||||
settleGraphLayout(graphStore);
|
settleGraphLayout(graphStore);
|
||||||
@@ -1346,46 +1369,63 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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
|
// Same source-wide-abort law as the chat door: stop the
|
||||||
// run; isolated failures keep going.
|
// 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);
|
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(
|
console.info(
|
||||||
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
|
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
|
||||||
`(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
|
`(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
|
||||||
);
|
);
|
||||||
return stats;
|
return stats;
|
||||||
} catch (err) {
|
} 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);
|
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 {
|
} finally {
|
||||||
inFlight.delete(id);
|
|
||||||
finishExtraction(id); // done or failed — no longer running
|
finishExtraction(id); // done or failed — no longer running
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import ONE raw notes file: register the source (deterministic id per
|
/** Every explicit import processes the whole file. Identity, source digest,
|
||||||
* file path — re-import keeps the same identity and its usage history)
|
* auditor and usage history remain source-scoped; this is not exactly-once. */
|
||||||
* 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. */
|
|
||||||
async function importNotes(input: NotesImportInput): Promise<EmissionStats> {
|
async function importNotes(input: NotesImportInput): Promise<EmissionStats> {
|
||||||
const id = `src_${crypto.createHash("sha256").update(input.filePath).digest("hex").slice(0, 12)}`;
|
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);
|
// Acquire BEFORE source upsert: overlapping contents must not alter metadata.
|
||||||
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
|
if (inFlight.has(id)) throw new NotesImportError("busy", { nodes: 0, chains: 0 }, "This file is already being imported. Try again when it finishes.");
|
||||||
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`);
|
inFlight.add(id);
|
||||||
return { nodes: 0, chains: 0 };
|
try {
|
||||||
}
|
|
||||||
const importedAt = new Date().toISOString();
|
const importedAt = new Date().toISOString();
|
||||||
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
|
graphStore.upsertSource({ id, filePath: input.filePath, importedAt });
|
||||||
return runNotesExtraction({ id, ...input, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { runExtraction, importNotes };
|
return { runExtraction, importNotes };
|
||||||
|
|||||||
@@ -404,7 +404,6 @@ async function streamOnce(
|
|||||||
// normalizeContext is the library's only Context→TranscriptContext entry.
|
// normalizeContext is the library's only Context→TranscriptContext entry.
|
||||||
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
|
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
|
||||||
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
|
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
|
||||||
apiKey: process.env.OPENROUTER_API_KEY || undefined,
|
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
onProviderStreamEvent: onProviderStreamEvent as never,
|
onProviderStreamEvent: onProviderStreamEvent as never,
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -326,7 +326,6 @@ async function streamOnce(
|
|||||||
// normalizeContext is the library's only Context→TranscriptContext entry.
|
// normalizeContext is the library's only Context→TranscriptContext entry.
|
||||||
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
|
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
|
||||||
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
|
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
|
||||||
apiKey: process.env.OPENROUTER_API_KEY || undefined,
|
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
onProviderStreamEvent: onProviderStreamEvent as never,
|
onProviderStreamEvent: onProviderStreamEvent as never,
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -95,11 +95,9 @@ export interface NotesSource {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
/** ISO — when this file was imported (the checkpoint's foldedAt). */
|
/** ISO — when this file was imported (the checkpoint's foldedAt). */
|
||||||
importedAt: string;
|
importedAt: string;
|
||||||
/** sha256 of the imported text at the last EXTRACTION run — the
|
/** Legacy content fingerprint. Accepted when reading old records, but
|
||||||
* same-path re-import guard (owner, 2026-09-29): a matching
|
* ignored: every explicit notes import processes the whole file. New
|
||||||
* fingerprint is a TRUE no-op (no model calls, no usage record);
|
* imports do not compute or write this field. No migration is needed. */
|
||||||
* a mismatch or a legacy missing stamp re-runs the whole file.
|
|
||||||
* The mappings ledger used to give this idempotency by accident. */
|
|
||||||
contentFp?: string;
|
contentFp?: string;
|
||||||
/** The run's usage log — same append-only discipline as
|
/** The run's usage log — same append-only discipline as
|
||||||
* ChatRecord.extractions, idempotent per startedAt. */
|
* ChatRecord.extractions, idempotent per startedAt. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from "./chat-handler.ts";
|
|||||||
export * from "./events-stream.ts";
|
export * from "./events-stream.ts";
|
||||||
export * from "./chats.ts";
|
export * from "./chats.ts";
|
||||||
export * from "./graph.ts";
|
export * from "./graph.ts";
|
||||||
|
export { NotesImportError } from "../agent/subagents/extraction.ts";
|
||||||
export * from "./commit-positions.ts";
|
export * from "./commit-positions.ts";
|
||||||
export * from "./jobs.ts";
|
export * from "./jobs.ts";
|
||||||
export * from "./stores.ts";
|
export * from "./stores.ts";
|
||||||
|
|||||||
+201
-14
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { after, describe, it } from "node:test";
|
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 } from "@earendil-works/pi-ai";
|
||||||
@@ -44,6 +45,7 @@ import type { GraphStore } from "../src/core/ports.ts";
|
|||||||
import {
|
import {
|
||||||
abortExtraction,
|
abortExtraction,
|
||||||
createExtractionPipeline,
|
createExtractionPipeline,
|
||||||
|
NotesImportError,
|
||||||
extractionStatuses,
|
extractionStatuses,
|
||||||
transcriptView,
|
transcriptView,
|
||||||
sourceDigest,
|
sourceDigest,
|
||||||
@@ -1584,7 +1586,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable");
|
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 ledgers = memoryLedgers();
|
||||||
const mk = () =>
|
const mk = () =>
|
||||||
createExtractionPipeline({
|
createExtractionPipeline({
|
||||||
@@ -1596,23 +1598,21 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
||||||
assert.deepEqual(first, { nodes: 1, chains: 0 });
|
assert.deepEqual(first, { nodes: 1, chains: 0 });
|
||||||
|
|
||||||
// Unchanged text: the content fingerprint matches — no extraction
|
// A legacy stamp must never suppress an explicit import.
|
||||||
// at all (no model calls, no usage record; the mappings ledger
|
ledgers.upsertSource({ ...ledgers.readSources()[0]!, contentFp: createHash("sha256").update(NOTES_TEXT).digest("hex").slice(0, 16) });
|
||||||
// used to give this no-op at merge time by accident).
|
|
||||||
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT });
|
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().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.readSources()[0]!.extractions?.length, 2, "both runs bill usage");
|
||||||
assert.equal(ledgers.readNodes().length, 1, "no duplicate node");
|
assert.equal(ledgers.readNodes().length, 2, "the auditor, not a content skip, owns identity");
|
||||||
|
|
||||||
// Changed text under the same path: the fingerprint misses — the
|
// Changed text also re-runs the whole file.
|
||||||
// whole file re-runs (fresh merge; the auditor's links would dedupe
|
|
||||||
// identity on a real model).
|
|
||||||
const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` });
|
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.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.readSources()[0]!.extractions?.length, 3, "the re-run bills its usage");
|
||||||
assert.equal(ledgers.readNodes().length, 2);
|
assert.equal(ledgers.readNodes().length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aborts like a chat run: true cancellation, nothing merged, partial usage in the source record", async () => {
|
it("aborts like a chat run: true cancellation, nothing merged, partial usage in the source record", async () => {
|
||||||
@@ -1627,7 +1627,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
|
|
||||||
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
|
const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
|
||||||
assert.equal(abortExtraction(srcId), true);
|
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.readNodes().length, 0, "nothing merged");
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
|
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
|
||||||
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted");
|
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted");
|
||||||
@@ -1935,7 +1935,7 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => {
|
|||||||
// SOURCE-WIDE abort: the parked job dies and the run ENDS — no
|
// SOURCE-WIDE abort: the parked job dies and the run ENDS — no
|
||||||
// later chunk ever mints (fresh rows would hang un-aborted).
|
// later chunk ever mints (fresh rows would hang un-aborted).
|
||||||
assert.equal(abortExtraction(srcId), true);
|
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(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry");
|
||||||
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
assert.equal(ledgers.readNodes().length, 0, "nothing merged");
|
||||||
// Per-job usage: the ONE minted job persisted its (failed) spend.
|
// Per-job usage: the ONE minted job persisted its (failed) spend.
|
||||||
@@ -2318,3 +2318,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");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -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, /<script>notes.md: <img>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, "");
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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: "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].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);
|
||||||
|
for (const { options } of requests) {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
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.provider !== "google") assert.equal(body.model, transport.id);
|
||||||
|
const tools = JSON.stringify(body.tools);
|
||||||
|
assert.match(tools, new RegExp(loop === "visual" ? "write_svg" : "pdf_extract"));
|
||||||
|
assert.match(tools, new RegExp(loop === "visual" ? "render_svg" : "pdf_render"));
|
||||||
|
if (transport.provider === "google") assert.ok(Array.isArray(body.contents));
|
||||||
|
else if (transport.provider === "openai") 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) {
|
||||||
|
assert.ok(json.includes(PNG.toString("base64")), "the actual rendered image must reach the selected provider");
|
||||||
|
assert.match(json, new RegExp(transport.provider === "google" ? "functionResponse" : transport.provider === "openai" ? "function_call_output" : transport.provider === "anthropic" ? "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.provider === "openrouter") {
|
||||||
|
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.provider === "openai") {
|
||||||
|
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.provider === "anthropic") {
|
||||||
|
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.provider === "openrouter" ? "data: [DONE]\n\n" : "");
|
||||||
|
return new Response(data, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||||
|
}
|
||||||
@@ -58,7 +58,8 @@ describe("SessionRow", () => {
|
|||||||
const html = render(item({ phase: "teach", pendingQuestion: true }));
|
const html = render(item({ phase: "teach", pendingQuestion: true }));
|
||||||
assert.ok(!html.includes("session-dot"), "no dots");
|
assert.ok(!html.includes("session-dot"), "no dots");
|
||||||
assert.ok(!html.includes("braille-spinner"), "motion is reserved for working");
|
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", () => {
|
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", () => {
|
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" }));
|
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("messages"), "count yields to the state");
|
||||||
assert.ok(!awaiting.includes("braille-spinner"), "…and the spinner stays off");
|
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");
|
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)", () => {
|
it("active row is visually marked by the background alone (owner, round 5: no weight change)", () => {
|
||||||
const html = render(item({}), true);
|
const html = render(item({}), true);
|
||||||
assert.ok(html.includes("bg-accent"), "active background");
|
assert.ok(html.includes("bg-accent"), "active background");
|
||||||
|
|||||||
Reference in New Issue
Block a user