Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a2db775d | ||
|
|
6788a01add | ||
|
|
102aa6f7ae | ||
|
|
42abeb65ae | ||
|
|
8691470d03 | ||
|
|
b8abad2b4f | ||
|
|
0fc6d4ab94 | ||
|
|
ae2fad56c4 | ||
|
|
b6bc9edcda | ||
|
|
480064b19e | ||
|
|
7fb891389d | ||
|
|
4bd20be529 | ||
|
|
6cba9c8c5a | ||
|
|
cc657faa93 | ||
|
|
6685b3d09a | ||
|
|
b15db907ab | ||
|
|
93017d767e | ||
|
|
388addff3e | ||
|
|
c36c1c6b4d | ||
|
|
a67366005a | ||
|
|
2d67376b76 | ||
|
|
aef01c44a0 | ||
|
|
261a9b9927 | ||
|
|
1c5024c8c2 | ||
|
|
01e7b17fcd | ||
|
|
c4c15b8fd2 | ||
|
|
7f95753cf2 |
@@ -63,5 +63,7 @@ jobs:
|
|||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
ELECTRON_RUN_AS_NODE: "1"
|
ELECTRON_RUN_AS_NODE: "1"
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
# Check the fresh trace before any static copying/curation.
|
||||||
|
- run: node scripts/smoke-provider-runtime.mjs
|
||||||
- run: node scripts/build-server.mjs
|
- run: node scripts/build-server.mjs
|
||||||
- run: node scripts/smoke-server.mjs --electron
|
- run: node scripts/smoke-server.mjs --electron
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ jobs:
|
|||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
ELECTRON_RUN_AS_NODE: "1"
|
ELECTRON_RUN_AS_NODE: "1"
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
# Check the fresh trace before any static copying/curation.
|
||||||
|
- run: node scripts/smoke-provider-runtime.mjs
|
||||||
- run: node scripts/build-server.mjs
|
- run: node scripts/build-server.mjs
|
||||||
- run: node scripts/smoke-server.mjs --electron
|
- run: node scripts/smoke-server.mjs --electron
|
||||||
- name: Validate macOS release secrets
|
- name: Validate macOS release secrets
|
||||||
@@ -169,6 +171,25 @@ jobs:
|
|||||||
- name: Windows/Linux rehearsal (no publish)
|
- name: Windows/Linux rehearsal (no publish)
|
||||||
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
|
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
|
||||||
run: npx electron-builder ${{ matrix.builder_args }} --publish never
|
run: npx electron-builder ${{ matrix.builder_args }} --publish never
|
||||||
|
- name: Initialize packaged providers offline (host and packaged Electron Node)
|
||||||
|
run: |
|
||||||
|
case "$RUNNER_OS" in
|
||||||
|
macOS)
|
||||||
|
app="dist/mac-arm64/LearnHarness.app/Contents"
|
||||||
|
payload="$app/Resources/server/standalone"
|
||||||
|
runtime="$app/MacOS/LearnHarness"
|
||||||
|
;;
|
||||||
|
Windows)
|
||||||
|
payload="dist/win-unpacked/resources/server/standalone"
|
||||||
|
runtime="dist/win-unpacked/LearnHarness.exe"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
payload="dist/linux-unpacked/resources/server/standalone"
|
||||||
|
runtime="dist/linux-unpacked/learnharness"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone "$payload"
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone "$payload" --runtime "$runtime"
|
||||||
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
|
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
|
||||||
run: |
|
run: |
|
||||||
case "$RUNNER_OS" in
|
case "$RUNNER_OS" in
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { modelCatalog } from "@/src/server";
|
import { modelCatalog, refreshConnectionCatalog } from "@/src/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model picker's data (model-control wave): pi-ai's configured-provider
|
* The model picker's data (model-control wave): pi-ai's configured-provider
|
||||||
@@ -7,5 +7,6 @@ import { modelCatalog } from "@/src/server";
|
|||||||
* catalog: no network, stable within a pi-ai version.
|
* catalog: no network, stable within a pi-ai version.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return Response.json(modelCatalog());
|
await refreshConnectionCatalog();
|
||||||
|
return Response.json(modelCatalog(), { headers: { "Cache-Control": "no-store" } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon,
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||||
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
|
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
|
||||||
import { formatTokens, formatUsd } from "@/components/usage-format";
|
import { formatTokens, planCostLabel } from "@/components/usage-format";
|
||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,7 +348,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
|||||||
Background agents
|
Background agents
|
||||||
{blobs.length > 0 && (
|
{blobs.length > 0 && (
|
||||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||||
{totalCost === null ? "—" : formatUsd(totalCost)}
|
{planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +423,7 @@ function ActivityBlobRow({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
||||||
<span data-testid="activity-blob-cost">{blob.cost === null ? "—" : formatUsd(blob.cost)}</span>
|
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}</span>
|
||||||
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -462,7 +462,7 @@ function ActivityBlobRow({
|
|||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Cost</span>
|
<span>Cost</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{blob.cost === null ? "—" : formatUsd(blob.cost)}
|
{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
|
|||||||
+24
-24
@@ -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";
|
||||||
@@ -293,9 +294,17 @@ function timeAgo(startedAt: number): string {
|
|||||||
|
|
||||||
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
||||||
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
||||||
function fmtRunCost(dollars: number | null): string {
|
function fmtRunCost(dollars: number | null, subscription = false): string {
|
||||||
|
if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`;
|
||||||
return dollars === null ? "—" : `$${dollars.toFixed(3)}`;
|
return dollars === null ? "—" : `$${dollars.toFixed(3)}`;
|
||||||
}
|
}
|
||||||
|
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
|
||||||
|
const stages = [costs.stages.extraction, costs.stages.audit];
|
||||||
|
const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0);
|
||||||
|
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
|
||||||
|
const dollars = displayCostTotals(costs).totalDollars;
|
||||||
|
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${dollars.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cost figures the UI DISPLAYS (owner refinement round, 2026-09-24):
|
* The cost figures the UI DISPLAYS (owner refinement round, 2026-09-24):
|
||||||
@@ -348,7 +357,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
|||||||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
${displayCostTotals(costs).totalDollars.toFixed(2)}
|
{graphUsageLabel(costs)}
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -377,7 +386,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
];
|
];
|
||||||
// The displayed figures come from displayCostTotals (naming excluded —
|
// The displayed figures come from displayCostTotals (naming excluded —
|
||||||
// the pill and this panel must agree).
|
// the pill and this panel must agree).
|
||||||
const { totalDollars, unpricedRuns } = displayCostTotals(costs);
|
const { unpricedRuns } = displayCostTotals(costs);
|
||||||
return (
|
return (
|
||||||
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
||||||
<div className="mb-2 flex items-baseline justify-between">
|
<div className="mb-2 flex items-baseline justify-between">
|
||||||
@@ -385,7 +394,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
Graph pipeline costs
|
Graph pipeline costs
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground tabular-nums">
|
<span className="text-foreground tabular-nums">
|
||||||
${totalDollars.toFixed(2)}
|
{graphUsageLabel(costs)}
|
||||||
{unpricedRuns > 0 && (
|
{unpricedRuns > 0 && (
|
||||||
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
|
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
|
||||||
)}
|
)}
|
||||||
@@ -396,8 +405,8 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
<span className="min-w-0 text-right tabular-nums">
|
<span className="min-w-0 text-right tabular-nums">
|
||||||
{fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)}
|
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 0)}
|
||||||
{stage.unpricedRuns > 0 && stage.dollars > 0 && (
|
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
|
||||||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@@ -421,7 +430,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars)}</span>
|
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1088,29 +1097,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);
|
const { importing, onImportFiles } = notesImport;
|
||||||
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));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 +1613,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>
|
||||||
|
|
||||||
|
|||||||
+29
-40
@@ -8,7 +8,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
|
|||||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
import { composeSlotModel } from "@/components/settings/agent-section";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
|
|
||||||
/** GET /api/models is cached per page load for instant menu opens, but
|
/** GET /api/models is cached per page load for instant menu opens, but
|
||||||
@@ -17,51 +17,40 @@ import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/t
|
|||||||
* every model-menu open (stale-while-revalidate: the cached list shows
|
* every model-menu open (stale-while-revalidate: the cached list shows
|
||||||
* instantly, the fresh one swaps in while the menu is open). */
|
* instantly, the fresh one swaps in while the menu is open). */
|
||||||
let catalogCache: ModelCatalogResponse | undefined;
|
let catalogCache: ModelCatalogResponse | undefined;
|
||||||
let catalogInflight: Promise<ModelCatalogResponse> | undefined;
|
let catalogInflight: Promise<void> | undefined;
|
||||||
|
let catalogGeneration = 0;
|
||||||
function fetchCatalog(force = false): Promise<ModelCatalogResponse> {
|
let stopCatalogSignals: (() => void) | undefined;
|
||||||
if (force) {
|
const catalogReaders = new Set<() => void>();
|
||||||
catalogCache = undefined;
|
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
|
||||||
catalogInflight = undefined;
|
function fetchCatalog(revalidate = false): void {
|
||||||
}
|
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
|
||||||
catalogInflight ??= fetch("/api/models")
|
if (catalogInflight) return;
|
||||||
.then((r) => {
|
const generation = catalogGeneration;
|
||||||
if (!r.ok) throw new Error(`catalog request failed: ${r.status}`);
|
catalogInflight = fetch("/api/models")
|
||||||
return r.json() as Promise<ModelCatalogResponse>;
|
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
|
||||||
})
|
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
|
||||||
.then((c) => {
|
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
|
||||||
catalogCache = c;
|
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
|
||||||
return c;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
catalogInflight = undefined; // retry on next mount/open
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
return catalogInflight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
||||||
const [catalog, setCatalog] = useState(catalogCache);
|
const [catalog, setCatalog] = useState(catalogCache);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (catalogCache) return;
|
const first = catalogReaders.size === 0;
|
||||||
let alive = true;
|
const read = () => setCatalog(catalogCache);
|
||||||
fetchCatalog()
|
catalogReaders.add(read);
|
||||||
.then((c) => alive && setCatalog(c))
|
if (first) {
|
||||||
.catch(() => {
|
stopCatalogSignals = onSettingsSaved((section) => {
|
||||||
/* the picker stays closed on failure; the buttons still
|
if (section === "keys") {
|
||||||
display the strong slot from the record fetch */
|
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||||
});
|
publishCatalog(); fetchCatalog();
|
||||||
return () => {
|
} else if (section === "models") fetchCatalog(true);
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
fetchCatalog(true)
|
|
||||||
.then(setCatalog)
|
|
||||||
.catch(() => {
|
|
||||||
/* refresh is best-effort: the cached list stays on failure */
|
|
||||||
});
|
});
|
||||||
|
fetchCatalog(true);
|
||||||
|
} else fetchCatalog();
|
||||||
|
read();
|
||||||
|
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
|
||||||
}, []);
|
}, []);
|
||||||
|
const refresh = useCallback(() => fetchCatalog(true), []);
|
||||||
return { catalog, refresh };
|
return { catalog, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))}</>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ArtifactPartProps } from "@/src/core";
|
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice transcript cards (practice sessions, owner design
|
* The practice transcript cards (practice sessions, owner design
|
||||||
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
|
|||||||
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{statement}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
||||||
{verdict.length > 0 && (
|
{verdict.length > 0 && (
|
||||||
<div className="text-sm text-foreground/50">
|
<div className="text-sm text-foreground/50">
|
||||||
<MessageResponse>{verdict}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Then the divider, then the labeled model solution (Slice G: the
|
{/* Then the divider, then the labeled model solution (Slice G: the
|
||||||
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{reveal}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { repairMathForDisplay } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice dock (practice sessions, owner design 2026-10-04): an
|
* The practice dock (practice sessions, owner design 2026-10-04): an
|
||||||
@@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{input.statement}</MessageResponse>
|
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { prettyModel } from "@/components/usage-format";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||||
|
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||||
|
|
||||||
/** One slot's display identity — the tier's roster as the row hint rides
|
/** One slot's display identity — the tier's roster as the row hint rides
|
||||||
* with the resolved default (owner: the hint ALWAYS names the default). */
|
* with the resolved default (owner: the hint ALWAYS names the default). */
|
||||||
@@ -131,19 +132,6 @@ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean)
|
|||||||
return { ...agent, graphTools: { enabled: on } };
|
return { ...agent, graphTools: { enabled: on } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The research tool runs on the search port — the teacher registry DROPS
|
|
||||||
* it when no engine is live (chat-handler's searchAvailable, the same
|
|
||||||
* /api/search signal the section fetches). Pure — exported for tests.
|
|
||||||
* Teacher-stream flag 2026-09-22: the settings row grays on the SAME
|
|
||||||
* signal, so the row and the actual registry availability can never
|
|
||||||
* disagree. `search === null` (loading/failed) grays NOTHING. */
|
|
||||||
export function researchToolUnavailable(
|
|
||||||
toolId: string,
|
|
||||||
search: { available: boolean } | null,
|
|
||||||
): boolean {
|
|
||||||
return toolId === "research" && search !== null && !search.available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compose: one slot's model/thinking override. Empty model = absent
|
/** Compose: one slot's model/thinking override. Empty model = absent
|
||||||
* override (Default — falls to the factory pin); empty thinking = the
|
* override (Default — falls to the factory pin); empty thinking = the
|
||||||
* tier keeps its resolved thinking budget. Per-field independence, same
|
* tier keeps its resolved thinking budget. Per-field independence, same
|
||||||
@@ -357,18 +345,23 @@ export function AgentSection({
|
|||||||
// fetch each per section mount. The models read ALSO re-runs on the
|
// fetch each per section mount. The models read ALSO re-runs on the
|
||||||
// cross-surface signal (fix wave: another surface's write re-reads
|
// cross-surface signal (fix wave: another surface's write re-reads
|
||||||
// into the slot rows live).
|
// into the slot rows live).
|
||||||
const readModels = useCallback(() => {
|
const modelsRequest = useRef(0);
|
||||||
|
const modelsMounted = useRef(false);
|
||||||
|
const readModels = useCallback((connectionChanged = false) => {
|
||||||
|
const request = ++modelsRequest.current;
|
||||||
|
if (connectionChanged) setCatalog(null);
|
||||||
fetch("/api/models")
|
fetch("/api/models")
|
||||||
.then((r) => r.json())
|
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
|
||||||
.then((body: ModelsBody) => {
|
.then((body: ModelsBody) => {
|
||||||
|
if (!modelsMounted.current || request !== modelsRequest.current) return;
|
||||||
setCatalog(body.models);
|
setCatalog(body.models);
|
||||||
setSlots(body.slots);
|
setSlots(body.slots);
|
||||||
})
|
})
|
||||||
.catch(() => setCatalog(null));
|
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useSettingsSaved((section) => {
|
useSettingsSaved((section) => {
|
||||||
if (section === "models") readModels();
|
if (section === "models" || section === "keys") readModels(section === "keys");
|
||||||
// THE state-echo root cause: the prompt switches write through the
|
// THE state-echo root cause: the prompt switches write through the
|
||||||
// agent section, but this view (the toggles' truth) never re-read —
|
// agent section, but this view (the toggles' truth) never re-read —
|
||||||
// the settings-saved law (every reader re-reads) was missing this
|
// the settings-saved law (every reader re-reads) was missing this
|
||||||
@@ -377,6 +370,7 @@ export function AgentSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
modelsMounted.current = true;
|
||||||
readModels();
|
readModels();
|
||||||
fetch("/api/settings/prompts")
|
fetch("/api/settings/prompts")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -393,6 +387,7 @@ export function AgentSection({
|
|||||||
setEngineKeys(present);
|
setEngineKeys(present);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
return () => { modelsMounted.current = false; modelsRequest.current++; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The reason line re-reads /api/search AFTER an engine write lands —
|
// The reason line re-reads /api/search AFTER an engine write lands —
|
||||||
@@ -653,6 +648,7 @@ export function AgentSection({
|
|||||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||||
className="relative flex items-center gap-2"
|
className="relative flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
{slot !== "strong" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
emptyLabel="—"
|
emptyLabel="—"
|
||||||
@@ -792,28 +788,19 @@ export function AgentSection({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{TOGGLE_TOOLS.map((id) => {
|
{TOGGLE_TOOLS.map((id) => (
|
||||||
// Research needs a live search engine; the row + toggle gray
|
<SettingsRow
|
||||||
// on the same signal the registry drops the tool on (see
|
key={id}
|
||||||
// researchToolUnavailable). The reason rides as the hint.
|
label={`Tool · ${id}`}
|
||||||
const searchDown = researchToolUnavailable(id, search);
|
testid={`tool-row-${id}`}
|
||||||
return (
|
>
|
||||||
<SettingsRow
|
<Toggle
|
||||||
key={id}
|
checked={agent?.tools?.[id] ?? true}
|
||||||
label={`Tool · ${id}`}
|
label={`tool ${id}`}
|
||||||
hint={searchDown ? search?.reason : undefined}
|
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
||||||
testid={`tool-row-${id}`}
|
/>
|
||||||
dimmed={searchDown}
|
</SettingsRow>
|
||||||
>
|
))}
|
||||||
<Toggle
|
|
||||||
checked={agent?.tools?.[id] ?? true}
|
|
||||||
disabled={searchDown}
|
|
||||||
label={`tool ${id}`}
|
|
||||||
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const engine = agent?.search?.engine ?? null;
|
const engine = agent?.search?.engine ?? null;
|
||||||
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
|
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { ChatGPTStatus } from "@/src/core";
|
||||||
|
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Toggle } from "./controls";
|
||||||
|
|
||||||
|
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
||||||
|
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||||
|
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||||
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
|
export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." };
|
||||||
|
const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
|
||||||
|
type Account = ChatGPTStatus["accounts"][number];
|
||||||
|
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" };
|
||||||
|
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
|
||||||
|
const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again.";
|
||||||
|
function isStatus(value: unknown): value is ChatGPTStatus {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const s = value as ChatGPTStatus;
|
||||||
|
return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
|
||||||
|
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
|
||||||
|
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean"));
|
||||||
|
}
|
||||||
|
function replyError(value: unknown): string {
|
||||||
|
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
|
||||||
|
}
|
||||||
|
const Context = createContext({
|
||||||
|
status: empty,
|
||||||
|
busy: false,
|
||||||
|
error: null as string | null,
|
||||||
|
inlineError: null as string | null,
|
||||||
|
warning: null as string | null,
|
||||||
|
connect: () => {},
|
||||||
|
manage: (_id: string) => {},
|
||||||
|
select: (_id: string) => {},
|
||||||
|
dismissWarning: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
|
||||||
|
* are used; account IDs remain control values, never displayed account labels. */
|
||||||
|
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<ChatGPTStatus>(empty);
|
||||||
|
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||||
|
const [background, setBackground] = useState(true);
|
||||||
|
const [backgroundDirty, setBackgroundDirty] = useState(false);
|
||||||
|
const [registration, setRegistration] = useState("");
|
||||||
|
const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null);
|
||||||
|
const generation = useRef(0);
|
||||||
|
const statusSequence = useRef(0);
|
||||||
|
const mounted = useRef(false);
|
||||||
|
const dialogRef = useRef<Dialog | null>(null);
|
||||||
|
const statusRef = useRef(status);
|
||||||
|
const loginAttempt = useRef<LoginAttempt | null>(null);
|
||||||
|
const receive = useRef<(value: unknown) => void>(() => {});
|
||||||
|
function showDialog(next: Dialog | null) { dialogRef.current = next; setDialog(next); }
|
||||||
|
function publishStatus(value: unknown): ChatGPTStatus | null {
|
||||||
|
if (!isStatus(value)) { setError(replyError(value)); return null; }
|
||||||
|
statusSequence.current++; statusRef.current = value; setStatus(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function owns(attempt: LoginAttempt) { return mounted.current && loginAttempt.current === attempt && generation.current === attempt.generation; }
|
||||||
|
function finish(attempt: LoginAttempt) {
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
loginAttempt.current = null; operation.current = null; setBusy(false);
|
||||||
|
}
|
||||||
|
async function complete(attempt: LoginAttempt, next: ChatGPTStatus) {
|
||||||
|
const account = next.accounts.find((a) => a.id === next.activeId && a.connected);
|
||||||
|
if (next.error || !next.available || !account || (attempt.registration && account.id !== attempt.registration)) {
|
||||||
|
finish(attempt); setError(next.error || CONTROL_ERROR); return;
|
||||||
|
}
|
||||||
|
attempt.phase = "consent"; attempt.accountId = account.id;
|
||||||
|
let replyFailure: string | null = null;
|
||||||
|
try {
|
||||||
|
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
|
||||||
|
operation.current = "change";
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
|
||||||
|
replyFailure = result.error;
|
||||||
|
let saved: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const value: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
|
||||||
|
} else saved = publishStatus(result);
|
||||||
|
if (!saved) { finish(attempt); return; }
|
||||||
|
next = saved;
|
||||||
|
}
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected);
|
||||||
|
if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR);
|
||||||
|
else if (dialogRef.current === attempt.dialog) showDialog(null);
|
||||||
|
} catch { if (owns(attempt)) setError(CONTROL_ERROR); }
|
||||||
|
finally { finish(attempt); }
|
||||||
|
}
|
||||||
|
function reconcile(next: ChatGPTStatus, loginReply = false) {
|
||||||
|
const attempt = loginAttempt.current;
|
||||||
|
if (!attempt || !owns(attempt)) return;
|
||||||
|
if (attempt.phase === "consent") {
|
||||||
|
if (next.pending || next.error || !next.available || next.activeId !== attempt.accountId || !next.accounts.some((a) => a.id === attempt.accountId && a.connected)) {
|
||||||
|
finish(attempt); setError(next.error || CONTROL_ERROR);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.pending) { attempt.pending = true; attempt.phase = "pending"; return; }
|
||||||
|
// login() normally acknowledges pending immediately; verified completion is
|
||||||
|
// a later status notification. The acknowledgement itself is not success.
|
||||||
|
if (attempt.pending || loginReply) void complete(attempt, next);
|
||||||
|
}
|
||||||
|
receive.current = (value) => {
|
||||||
|
const next = publishStatus(value);
|
||||||
|
if (next) reconcile(next);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
const bridge = window.lh?.chatgpt;
|
||||||
|
const refresh = async () => {
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
try {
|
||||||
|
const next: unknown = await bridge?.status();
|
||||||
|
if (next && mounted.current && request === statusSequence.current) receive.current(next);
|
||||||
|
} catch { if (mounted.current && request === statusSequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
const off = bridge?.onChange(() => void refresh());
|
||||||
|
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
|
||||||
|
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
||||||
|
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
|
||||||
|
const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
|
||||||
|
function openConnect() {
|
||||||
|
if (operation.current) return;
|
||||||
|
setBackground(statusRef.current.allowBackgroundUse);
|
||||||
|
setBackgroundDirty(false);
|
||||||
|
setRegistration("");
|
||||||
|
setError(null);
|
||||||
|
setDismissedError(status.error);
|
||||||
|
showDialog({ kind: "connect" });
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return;
|
||||||
|
const current = dialogRef.current;
|
||||||
|
if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; }
|
||||||
|
const cancel = loginAttempt.current !== null || statusRef.current.pending;
|
||||||
|
const intent = ++generation.current;
|
||||||
|
const owner = dialogRef.current;
|
||||||
|
loginAttempt.current = null;
|
||||||
|
if (!cancel) showDialog(null);
|
||||||
|
setError(null);
|
||||||
|
setDismissedError(status.error);
|
||||||
|
if (cancel) {
|
||||||
|
// Keep cancellation serialized even after login's pending IPC reply.
|
||||||
|
operation.current = "cancel"; setBusy(true);
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
|
||||||
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
|
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||||
|
let next: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const current: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
|
} else next = publishStatus(value);
|
||||||
|
if (next && !next.pending) {
|
||||||
|
setDismissedError(value.error); // Dismiss only this cancellation's error, not a newer failure.
|
||||||
|
if (dialogRef.current === owner) showDialog(null);
|
||||||
|
} else if (next) setError(CONTROL_ERROR);
|
||||||
|
}).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => {
|
||||||
|
if (mounted.current && generation.current === intent) { operation.current = null; setBusy(false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function control(kind: "change" | "disconnect", action: () => Promise<ChatGPTStatus>, target?: string) {
|
||||||
|
if (operation.current) return;
|
||||||
|
const intent = ++generation.current;
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
const owner = dialogRef.current;
|
||||||
|
operation.current = kind;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
if (kind === "disconnect") setDismissedError(null);
|
||||||
|
try {
|
||||||
|
const value: unknown = await action();
|
||||||
|
if (!mounted.current || intent !== generation.current) return;
|
||||||
|
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||||
|
let next: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const current: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!mounted.current || intent !== generation.current) return;
|
||||||
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
|
} else next = publishStatus(value);
|
||||||
|
if (!next) return;
|
||||||
|
// Dismissed status warnings belong to an earlier action. Only a new
|
||||||
|
// disconnect outcome (or an explicit IPC error envelope above) reopens one.
|
||||||
|
const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null;
|
||||||
|
if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) {
|
||||||
|
if (dialogRef.current === owner) {
|
||||||
|
if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" });
|
||||||
|
else if (!failure) showDialog(null);
|
||||||
|
}
|
||||||
|
setError(failure);
|
||||||
|
} else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null));
|
||||||
|
} catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); }
|
||||||
|
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
|
||||||
|
}
|
||||||
|
async function login() {
|
||||||
|
const bridge = window.lh?.chatgpt;
|
||||||
|
const owner = dialogRef.current;
|
||||||
|
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
|
||||||
|
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
|
||||||
|
loginAttempt.current = attempt; operation.current = "login";
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
setBusy(true); setError(null); setDismissedError(null);
|
||||||
|
try {
|
||||||
|
let value: unknown = await bridge.login(registration || undefined);
|
||||||
|
if (!owns(attempt) || attempt.phase === "consent") return;
|
||||||
|
// Never publish an old acknowledgement over a newer event. If events
|
||||||
|
// coalesced past pending before the acknowledgement arrived, read the
|
||||||
|
// current backend state after it rather than losing that completion.
|
||||||
|
if (request !== statusSequence.current && isStatus(value)) {
|
||||||
|
if (value.error) { setError(value.error); finish(attempt); return; }
|
||||||
|
attempt.pending = true; attempt.phase = "pending";
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
value = await bridge.status();
|
||||||
|
if (!owns(attempt) || loginAttempt.current?.phase === "consent" || refresh !== statusSequence.current) return;
|
||||||
|
}
|
||||||
|
const next = publishStatus(value);
|
||||||
|
if (!next) { finish(attempt); return; }
|
||||||
|
reconcile(next, true);
|
||||||
|
} catch { if (owns(attempt)) { setError(CONTROL_ERROR); finish(attempt); } }
|
||||||
|
// No finally: pending OAuth outlives this IPC promise. Only its status
|
||||||
|
// completion, failure, or explicit cancellation releases the attempt.
|
||||||
|
}
|
||||||
|
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
|
||||||
|
const context = {
|
||||||
|
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
|
||||||
|
connect: openConnect,
|
||||||
|
manage: (id: string) => {
|
||||||
|
const account = statusRef.current.accounts.find((a) => a.id === id);
|
||||||
|
if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); }
|
||||||
|
},
|
||||||
|
select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
|
||||||
|
dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
|
||||||
|
};
|
||||||
|
return <Context.Provider value={context}>
|
||||||
|
{children}
|
||||||
|
{dialog && <AccountDialog key={dialog.kind} onClose={close} busy={busy && operation.current !== "login"}>
|
||||||
|
{dialog.kind === "warning" ? <>
|
||||||
|
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
|
||||||
|
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
|
||||||
|
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
||||||
|
<h2 className="text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</h2>
|
||||||
|
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
|
||||||
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
|
||||||
|
<Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>Disconnect account</Button>
|
||||||
|
</div>
|
||||||
|
</div> : <>
|
||||||
|
{dialog.kind === "connect" ? <>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Continue with ChatGPT</h2>
|
||||||
|
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
||||||
|
<option value="">Add another account or workspace</option>
|
||||||
|
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||||
|
</select>
|
||||||
|
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
||||||
|
</div>}
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||||
|
</> : managed && <>
|
||||||
|
<div data-testid="chatgpt-managed-identity" className="flex items-center gap-2 text-xs text-foreground">
|
||||||
|
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
|
||||||
|
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||||
|
</select> : <span>{managed.email || "ChatGPT account"}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center" data-testid="chatgpt-account-actions">
|
||||||
|
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||||
|
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||||
|
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
||||||
|
else if (bridge) void control("change", () => bridge.consent(enabled));
|
||||||
|
}} />
|
||||||
|
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
|
||||||
|
</div>
|
||||||
|
{dialog.kind === "manage" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
||||||
|
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
||||||
|
</div>
|
||||||
|
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
|
||||||
|
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
||||||
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
|
||||||
|
{dialog.kind === "manage" && <Button variant="outline" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer disabled:cursor-not-allowed" onClick={openConnect} disabled={busy || status.pending || !status.available}>Add accounts</Button>}
|
||||||
|
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
|
||||||
|
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</AccountDialog>}
|
||||||
|
</Context.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) {
|
||||||
|
return <Button type="button" variant="outline" size="sm" data-testid={testid} onClick={onClick} disabled={disabled} className="cursor-pointer disabled:cursor-not-allowed">
|
||||||
|
{/* Official unmodified mark; existing secondary button hover/focus treatment. */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" />
|
||||||
|
Continue with ChatGPT
|
||||||
|
</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
const backdropDismiss = useBackdropDismiss(onClose);
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
const focusable = () => [...(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])];
|
||||||
|
focusable()[0]?.focus();
|
||||||
|
const key = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault(); event.stopImmediatePropagation(); closeRef.current();
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
const items = focusable(), first = items[0], last = items.at(-1);
|
||||||
|
if (!first) { event.preventDefault(); return; }
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
|
||||||
|
else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Capture claims the nested dialog's Escape before outer Settings/setup.
|
||||||
|
document.addEventListener("keydown", key, true);
|
||||||
|
return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); };
|
||||||
|
}, []);
|
||||||
|
return createPortal(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid="chatgpt-dialog-backdrop" {...backdropDismiss}>
|
||||||
|
<div ref={ref} role="dialog" aria-modal="true" aria-label="ChatGPT account" aria-busy={busy} data-testid="chatgpt-account-dialog" onClick={(event) => event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}</div>
|
||||||
|
</div>, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact settings row, or setup's secondary action only. */
|
||||||
|
export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
|
||||||
|
const ui = useContext(Context);
|
||||||
|
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||||
|
const accounts = ui.status.accounts.filter((a) => a.connected);
|
||||||
|
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{!setup && active && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
|
||||||
|
{active && !setup ? <>
|
||||||
|
<select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.manage(active.id)}>Manage…</Button>
|
||||||
|
</> : <ContinueButton onClick={ui.connect} disabled={!ui.status.available || ui.busy || ui.status.pending} />}
|
||||||
|
</div>
|
||||||
|
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
|
||||||
|
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contextual consent discovery belongs beside background model choices only. */
|
||||||
|
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
|
||||||
|
const ui = useContext(Context);
|
||||||
|
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||||
|
if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null;
|
||||||
|
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>;
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
* a paste box on a key the UI cannot make live would mislead.
|
* a paste box on a key the UI cannot make live would mislead.
|
||||||
* - OpenRouter is NOT special: one provider among 33, a row only when
|
* - OpenRouter is NOT special: one provider among 33, a row only when
|
||||||
* stored, otherwise it lives in the Add dropdown like everyone else.
|
* stored, otherwise it lives in the Add dropdown like everyone else.
|
||||||
* - Tools & services (Deepgram, the Google pair) keep their own group,
|
* - Providers groups API keys, subscriptions, dictation and search.
|
||||||
* same look, same interaction.
|
* Unknown variables stay masked/read-only at the end of Model API keys.
|
||||||
*
|
*
|
||||||
* API: GET /api/keys (masked statuses + the providers array), PUT
|
* API: GET /api/keys (masked statuses + the providers array), PUT
|
||||||
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
|
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||||
import { SettingsGroup } from "./controls.tsx";
|
import { SettingsGroup } from "./controls.tsx";
|
||||||
|
import { ChatGPTConnection } from "./chatgpt-connection.tsx";
|
||||||
import { ProviderMenuList } from "./model-menu.tsx";
|
import { ProviderMenuList } from "./model-menu.tsx";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
@@ -412,7 +413,7 @@ export function KeysSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Model providers" testid="group-provider-keys">
|
<SettingsGroup label="Model API keys" testid="group-provider-keys">
|
||||||
{providerRows.map((status) => (
|
{providerRows.map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
key={status.id}
|
key={status.id}
|
||||||
@@ -446,10 +447,20 @@ export function KeysSection({
|
|||||||
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
|
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{unknownRows.length > 0 && <div data-testid="group-unknown-keys">
|
||||||
|
{unknownRows.map((status) => <div key={status.id} data-testid={`unknown-${status.id}`} className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40">
|
||||||
|
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
||||||
|
</div>)}
|
||||||
|
</div>}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="Tools & services" testid="group-tool-keys">
|
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
||||||
{toolRows.map((status) => (
|
<div data-testid="group-chatgpt"><ChatGPTConnection /></div>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||||
|
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
key={status.id}
|
key={status.id}
|
||||||
label={status.info?.label ?? status.id}
|
label={status.info?.label ?? status.id}
|
||||||
@@ -467,22 +478,8 @@ export function KeysSection({
|
|||||||
testid={`key-${status.id.toLowerCase()}`}
|
testid={`key-${status.id.toLowerCase()}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SettingsGroup>
|
</SettingsGroup>)}
|
||||||
|
|
||||||
{unknownRows.length > 0 && (
|
|
||||||
<SettingsGroup label="Other stored variables" testid="group-unknown-keys">
|
|
||||||
{unknownRows.map((status) => (
|
|
||||||
<div
|
|
||||||
key={status.id}
|
|
||||||
data-testid={`unknown-${status.id}`}
|
|
||||||
className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
|
|
||||||
>
|
|
||||||
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
|
||||||
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</SettingsGroup>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function ModelMenuList({
|
|||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-card-foreground">{m.name}</span>
|
<span className="truncate text-card-foreground">{m.name}</span>
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
|
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
|
||||||
{priceLabel(m.cost)}
|
{m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -49,17 +49,18 @@ import { KeysSection } from "./keys-section.tsx";
|
|||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
||||||
import { KeyboardSection } from "./keyboard-section.tsx";
|
import { KeyboardSection } from "./keyboard-section.tsx";
|
||||||
|
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
|
||||||
|
|
||||||
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
||||||
* prose is out; only the keyboard note stays (storage location is a
|
* prose is out; only the keyboard note stays (storage location is a
|
||||||
* real question users ask). */
|
* real question users ask). */
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ id: "general", label: "General" },
|
{ id: "keys", label: "Providers" },
|
||||||
{ id: "keys", label: "Keys" },
|
|
||||||
{ id: "agent", label: "Agent" },
|
{ id: "agent", label: "Agent" },
|
||||||
{ id: "appearance", label: "Appearance" },
|
{ id: "appearance", label: "Appearance" },
|
||||||
{ id: "graph", label: "Graph" },
|
{ id: "graph", label: "Graph" },
|
||||||
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
||||||
|
{ id: "general", label: "General" },
|
||||||
] as const;
|
] as const;
|
||||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
type SectionId = (typeof SECTIONS)[number]["id"];
|
||||||
|
|
||||||
@@ -83,20 +84,20 @@ export function SettingsDialogProvider({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [landOn, setLandOn] = useState<SectionId>("general");
|
const [landOn, setLandOn] = useState<SectionId>("keys");
|
||||||
const [landOnRow, setLandOnRow] = useState<string | null>(null);
|
const [landOnRow, setLandOnRow] = useState<string | null>(null);
|
||||||
const open = useCallback((section?: string, row?: string) => {
|
const open = useCallback((section?: string, row?: string) => {
|
||||||
if (section) setLandOn(section as SectionId);
|
setLandOn(section ? section as SectionId : "keys");
|
||||||
setLandOnRow(row ?? null);
|
setLandOnRow(row ?? null);
|
||||||
setShow(true);
|
setShow(true);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={{ open }}>
|
<ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
|
||||||
{children}
|
{children}
|
||||||
{show && (
|
{show && (
|
||||||
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
||||||
)}
|
)}
|
||||||
</DialogContext.Provider>
|
</DialogContext.Provider></ChatGPTUIProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ function SettingsDialog({
|
|||||||
* (portals render nothing under renderToStaticMarkup). */
|
* (portals render nothing under renderToStaticMarkup). */
|
||||||
export function SettingsDialogBody({
|
export function SettingsDialogBody({
|
||||||
onClose,
|
onClose,
|
||||||
initialSection = "general",
|
initialSection = "keys",
|
||||||
initialRow = null,
|
initialRow = null,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -376,7 +377,7 @@ export function SettingsDialogBody({
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{active === "keys" && (
|
{active === "keys" && (
|
||||||
<SettingsSection title="Keys" testid="section-keys">
|
<SettingsSection title="Providers" testid="section-keys">
|
||||||
<KeysSection initialRow={initialRow ?? undefined} />
|
<KeysSection initialRow={initialRow ?? undefined} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,12 +15,10 @@
|
|||||||
* its own save), the same re-read pattern chat.tsx uses for "models".
|
* its own save), the same re-read pattern chat.tsx uses for "models".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { AuthKeyStatus } from "@/src/core";
|
import type { AuthKeyStatus } from "@/src/core";
|
||||||
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { useModelCatalog } from "@/components/model-picker";
|
import { useModelCatalog } from "@/components/model-picker";
|
||||||
import type { ModelCatalogResponse } from "@/src/core";
|
import type { ModelCatalogResponse } from "@/src/core";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
|
||||||
|
|
||||||
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
||||||
* classifyKeys (the keys section's own classification — no drift):
|
* classifyKeys (the keys section's own classification — no drift):
|
||||||
@@ -40,22 +38,8 @@ export function hasProviderKey(
|
|||||||
* failed read claiming "zero keys" would be a lie the CTA flow then
|
* failed read claiming "zero keys" would be a lie the CTA flow then
|
||||||
* leans on). Re-reads on the keys signal. */
|
* leans on). Re-reads on the keys signal. */
|
||||||
export function useZeroKeys(): boolean {
|
export function useZeroKeys(): boolean {
|
||||||
const [zeroKeys, setZeroKeys] = useState(false);
|
const { catalog } = useModelCatalog();
|
||||||
const read = useCallback(() => {
|
return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false;
|
||||||
fetch("/api/keys")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body: { keys?: AuthKeyStatus[]; providers?: ProviderKeyInfo[] }) => {
|
|
||||||
setZeroKeys(!hasProviderKey(body.keys ?? [], body.providers));
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
read();
|
|
||||||
}, [read]);
|
|
||||||
useSettingsSaved((section) => {
|
|
||||||
if (section === "keys") read();
|
|
||||||
});
|
|
||||||
return zeroKeys;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pill itself — the models CTA's chrome and placement, the keys
|
/** The pill itself — the models CTA's chrome and placement, the keys
|
||||||
@@ -93,9 +77,6 @@ export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolea
|
|||||||
* (a provider key saved or deleted) and "models" (the strong slot
|
* (a provider key saved or deleted) and "models" (the strong slot
|
||||||
* re-pinned). Live-updates with the page, never mid-send. */
|
* re-pinned). Live-updates with the page, never mid-send. */
|
||||||
export function useStrongKeyless(): boolean {
|
export function useStrongKeyless(): boolean {
|
||||||
const { catalog, refresh } = useModelCatalog();
|
const { catalog } = useModelCatalog();
|
||||||
useSettingsSaved((section) => {
|
|
||||||
if (section === "keys" || section === "models") refresh();
|
|
||||||
});
|
|
||||||
return strongKeyless(catalog);
|
return strongKeyless(catalog);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
|
|||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||||
|
import { ChatGPTBackgroundAccess, ChatGPTConnection } from "@/components/settings/chatgpt-connection";
|
||||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
@@ -147,7 +148,6 @@ export function SetupKeysBody({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
||||||
<p className="text-sm text-foreground">Add a provider key.</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Choose your AI provider and paste its API key. You’ll pick your models next.
|
Choose your AI provider and paste its API key. You’ll pick your models next.
|
||||||
</p>
|
</p>
|
||||||
@@ -211,6 +211,7 @@ export function SetupKeysBody({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 border-t border-border/60 pt-4"><ChatGPTConnection setup /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,16 +266,19 @@ export function SetupDialog({
|
|||||||
// moment ago (stage 1's own screen) widens the pickers immediately.
|
// moment ago (stage 1's own screen) widens the pickers immediately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setBody(null);
|
||||||
setPicked({});
|
setPicked({});
|
||||||
setLevels({});
|
setLevels({});
|
||||||
setError(null);
|
setError(null);
|
||||||
fetch("/api/keys")
|
fetch("/api/keys")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? []))
|
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
||||||
.catch(() => setKeyProviders([]));
|
.catch(() => { if (!cancelled) setKeyProviders([]); });
|
||||||
fetch("/api/models")
|
fetch("/api/models")
|
||||||
.then((r) => r.json())
|
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
|
||||||
.then((b: ModelsBody) => {
|
.then((b: ModelsBody) => {
|
||||||
|
if (cancelled) return;
|
||||||
setBody(b);
|
setBody(b);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
// Prefill (owner, 2026-09-22): each pinned tier opens with its
|
// Prefill (owner, 2026-09-22): each pinned tier opens with its
|
||||||
@@ -292,7 +296,8 @@ export function SetupDialog({
|
|||||||
}
|
}
|
||||||
setLevels(preLevels);
|
setLevels(preLevels);
|
||||||
})
|
})
|
||||||
.catch(() => setFailed(true));
|
.catch(() => { if (!cancelled) setFailed(true); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [open, keysTick]);
|
}, [open, keysTick]);
|
||||||
|
|
||||||
// Esc + outside-click — layered dismissal, closable WITHOUT
|
// Esc + outside-click — layered dismissal, closable WITHOUT
|
||||||
@@ -472,6 +477,7 @@ export function SetupDialog({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
||||||
|
{slot !== "strong" && <ChatGPTBackgroundAccess provider={entry?.provider} />}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+12
-2
@@ -21,6 +21,7 @@ import { fuzzyScore } from "@/src/core";
|
|||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
import type { ChatListItem } from "@/src/core";
|
import type { ChatListItem } from "@/src/core";
|
||||||
import { kbdEventForAction } from "@/src/core";
|
import { kbdEventForAction } from "@/src/core";
|
||||||
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
||||||
@@ -47,6 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
|||||||
*
|
*
|
||||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
||||||
*/
|
*/
|
||||||
|
import type { ChatGPTControlBridge } from "@/src/core";
|
||||||
const SIDEBAR_AUTOCLOSE = 928;
|
const SIDEBAR_AUTOCLOSE = 928;
|
||||||
|
|
||||||
// Module scope, guarded for SSR (the server has no navigator).
|
// Module scope, guarded for SSR (the server has no navigator).
|
||||||
@@ -58,6 +60,7 @@ declare global {
|
|||||||
lh?: {
|
lh?: {
|
||||||
/** Read-only host OS; determines the native-control gutter. */
|
/** Read-only host OS; determines the native-control gutter. */
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
chatgpt?: ChatGPTControlBridge;
|
||||||
expandWindow: () => void;
|
expandWindow: () => void;
|
||||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||||
@@ -79,6 +82,13 @@ export function useSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const bridge = window.lh?.chatgpt;
|
||||||
|
if (!bridge) return;
|
||||||
|
const off = bridge.onChange(() => notifySettingsSaved("keys"));
|
||||||
|
notifySettingsSaved("keys"); // cover changes missed before this root-lifetime subscription
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
// Start closed so server and client first-render agree (no hydration
|
// Start closed so server and client first-render agree (no hydration
|
||||||
// mismatch); the stored value is applied after mount.
|
// mismatch); the stored value is applied after mount.
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -213,7 +223,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 +260,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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ export function StatsBar({
|
|||||||
const { catalog } = useModelCatalog();
|
const { catalog } = useModelCatalog();
|
||||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||||
const ctx = contextWindow({ turns }, limits);
|
const ctx = contextWindow({ turns }, limits);
|
||||||
const { cost, unpriced } = sessionCost({ turns });
|
const { cost, unpriced, subscription = 0 } = sessionCost({ turns });
|
||||||
// Honest cost: show dollars only when at least one turn priced;
|
// Honest cost: show dollars only when at least one turn priced;
|
||||||
// otherwise "—" (never a fake $0.0000).
|
// otherwise "—" (never a fake $0.0000).
|
||||||
const pricedCount = turns.length - unpriced;
|
const pricedCount = turns.length - unpriced - subscription;
|
||||||
const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
|
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
|
||||||
const lastModel = turns.at(-1)?.model;
|
const lastModel = turns.at(-1)?.model;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,9 +143,10 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Total cost</span>
|
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{subscription > 0 && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
|
||||||
{unpriced > 0 && (
|
{unpriced > 0 && (
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Unpriced turns</span>
|
<span>Unpriced turns</span>
|
||||||
@@ -168,7 +169,7 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||||
<span>{c === null ? "—" : formatUsd(c)}</span>
|
<span>{turn.model.startsWith("chatgpt:") ? "ChatGPT plan" : c === null ? "—" : formatUsd(c)}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export function formatUsd(c: number): string {
|
|||||||
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function planCostLabel(dollars: number | null, subscription: boolean, format = formatUsd): string {
|
||||||
|
if (subscription) return dollars === null ? "ChatGPT plan" : `${format(dollars)} API + ChatGPT plan`;
|
||||||
|
return dollars === null ? "—" : format(dollars);
|
||||||
|
}
|
||||||
|
|
||||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
||||||
export function formatTokens(n: number): string {
|
export function formatTokens(n: number): string {
|
||||||
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ files:
|
|||||||
- node_modules/universalify/**/*
|
- node_modules/universalify/**/*
|
||||||
- node_modules/ms/**/*
|
- node_modules/ms/**/*
|
||||||
- node_modules/argparse/**/*
|
- node_modules/argparse/**/*
|
||||||
|
# Main-process ID-token signature verification (no bundled OAuth implementation).
|
||||||
|
- node_modules/jose/**/*
|
||||||
extraResources:
|
extraResources:
|
||||||
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
||||||
# electron-builder hard-excludes a copy's root node_modules and the
|
# electron-builder hard-excludes a copy's root node_modules and the
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use strict";
|
||||||
|
const { publicError } = require("./chatgpt.cjs");
|
||||||
|
|
||||||
|
/** Inherited Node child IPC, not a listening HTTP/socket token service. */
|
||||||
|
function attachChatGPTChild(child, connection) {
|
||||||
|
const send = (value) => {
|
||||||
|
if (child.connected) child.send({ channel: "lh:chatgpt", ...value }, () => {});
|
||||||
|
};
|
||||||
|
const listener = async (message) => {
|
||||||
|
if (!message || message.channel !== "lh:chatgpt" || typeof message.id !== "string" || message.id.length > 100) return;
|
||||||
|
try {
|
||||||
|
let value;
|
||||||
|
if (message.op === "status") value = { status: await connection.status(), ...connection.stamp() };
|
||||||
|
else if (message.op === "models") value = await connection.models();
|
||||||
|
else if (message.op === "lease" && typeof message.background === "boolean") value = await connection.lease(message.background, message);
|
||||||
|
else throw new Error("Invalid private operation");
|
||||||
|
send({ id: message.id, op: message.op, ok: true, value });
|
||||||
|
} catch (err) { send({ id: message.id, op: message.op, ok: false, stamp: connection.stamp(), error: publicError(err) }); }
|
||||||
|
};
|
||||||
|
child.on("message", listener);
|
||||||
|
return { send, dispose: () => child.removeListener("message", listener) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sender must be the trusted window's main frame at its exact app origin. */
|
||||||
|
function trustedSender(event, origin, BrowserWindow) {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
try {
|
||||||
|
return !!win && event.senderFrame === event.sender.mainFrame && new URL(event.senderFrame.url).origin === origin;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
async function chatGPTControl(command, connection) {
|
||||||
|
try {
|
||||||
|
if (command?.op === "status") return await connection.status();
|
||||||
|
if (command?.op === "login" && (command.id === undefined || typeof command.id === "string")) return await connection.login(command.id);
|
||||||
|
if (command?.op === "cancel") return connection.cancelLogin();
|
||||||
|
if (command?.op === "select" && typeof command.id === "string") return await connection.select(command.id);
|
||||||
|
if (command?.op === "disconnect" && typeof command.id === "string") return await connection.disconnect(command.id);
|
||||||
|
if (command?.op === "logout" && typeof command.id === "string") return await connection.logout(command.id);
|
||||||
|
if (command?.op === "consent" && typeof command.enabled === "boolean") return await connection.consent(command.enabled);
|
||||||
|
return { error: "Unsupported ChatGPT control." };
|
||||||
|
} catch (err) { return { error: publicError(err) }; }
|
||||||
|
}
|
||||||
|
module.exports = { attachChatGPTChild, trustedSender, chatGPTControl };
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// LearnHarness-owned public-client OAuth. Never import pi's bundled login.
|
||||||
|
// This module is main-process-only; dependencies are injectable for scratch tests.
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const http = require("node:http");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ISSUER = "https://auth.openai.com";
|
||||||
|
const RESOURCE = "https://api.openai.com/v1";
|
||||||
|
const AUTHORIZE = `${ISSUER}/api/accounts/authorize`;
|
||||||
|
const TOKEN = `${ISSUER}/api/accounts/oauth/token`;
|
||||||
|
const JWKS = `${ISSUER}/.well-known/jwks.json`;
|
||||||
|
const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"];
|
||||||
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
|
const random = () => crypto.randomBytes(32).toString("base64url");
|
||||||
|
const fail = (code) => Object.assign(new Error(code), { code });
|
||||||
|
const publicError = (err) => ({
|
||||||
|
cancelled: "ChatGPT sign-in cancelled.",
|
||||||
|
timeout: "ChatGPT sign-in timed out. Try again.",
|
||||||
|
secure_storage: "Secure credential storage is unavailable. ChatGPT sign-in requires the desktop app and an OS secure-storage backend.",
|
||||||
|
storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.",
|
||||||
|
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
|
||||||
|
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
|
||||||
|
disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
|
||||||
|
background: "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.",
|
||||||
|
catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
|
||||||
|
expired: "The ChatGPT session expired. Reconnect in Providers.",
|
||||||
|
network: "ChatGPT could not be reached. Check your connection and try again.",
|
||||||
|
pending: "A ChatGPT sign-in is already pending.",
|
||||||
|
unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
|
||||||
|
}[err?.code] || "ChatGPT sign-in failed. Try again; no API-key fallback was used.");
|
||||||
|
|
||||||
|
const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
|
||||||
|
const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
|
||||||
|
/** Reject the entire encrypted record. Encryption is not schema validation. */
|
||||||
|
function validateState(state) {
|
||||||
|
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
|
||||||
|
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
|
||||||
|
const ids = new Set(); const clients = new Set();
|
||||||
|
for (const r of state.registrations) {
|
||||||
|
if (!r || !nonempty(r.id) || ids.has(r.id) || !nonempty(r.clientId) || r.clientId === "dynamic_agent_client" || clients.has(r.clientId) || r.issuer !== ISSUER || !nonempty(r.subject) || (Object.hasOwn(r, "background") && typeof r.background !== "boolean") || (r.email !== undefined && typeof r.email !== "string") || (Object.hasOwn(r, "reconnectRequired") && r.reconnectRequired !== true)) throw fail("storage");
|
||||||
|
ids.add(r.id); clients.add(r.clientId);
|
||||||
|
if (r.tokens !== undefined) {
|
||||||
|
const t = r.tokens;
|
||||||
|
if (!t || !nonempty(t.idToken) || !Array.isArray(t.scopes) || t.scopes.some((s) => !nonempty(s)) || !Number.isFinite(t.expiresAt) || t.expiresAt < 0 || (t.accessToken !== undefined && (!nonempty(t.accessToken) || t.accessToken.startsWith("sk-"))) || (t.refreshToken !== undefined && !nonempty(t.refreshToken))) throw fail("storage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.activeId !== null && !ids.has(state.activeId)) throw fail("storage");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypted envelope only, outside learning data. No plaintext fallback. */
|
||||||
|
function encryptedStore(file, safeStorage) {
|
||||||
|
function secure() {
|
||||||
|
if (!safeStorage.isEncryptionAvailable() || safeStorage.getSelectedStorageBackend?.() === "basic_text") throw fail("secure_storage");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
secure,
|
||||||
|
async read() {
|
||||||
|
secure();
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(await fs.readFile(file, "utf8"));
|
||||||
|
if (envelope.version !== 1 || typeof envelope.encrypted !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(envelope.encrypted) || !envelope.encrypted) throw fail("storage");
|
||||||
|
const state = JSON.parse(safeStorage.decryptString(Buffer.from(envelope.encrypted, "base64")));
|
||||||
|
return validateState(state);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") return null;
|
||||||
|
throw fail("storage");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async write(state) {
|
||||||
|
secure();
|
||||||
|
validateState(state);
|
||||||
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
const encrypted = safeStorage.encryptString(JSON.stringify(state)).toString("base64");
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
|
handle = await fs.open(temp, "wx", 0o600);
|
||||||
|
await handle.writeFile(JSON.stringify({ version: 1, encrypted }));
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
handle = null;
|
||||||
|
await fs.rename(temp, file);
|
||||||
|
// Directory fsync is supported on Unix; the file fsync above is mandatory.
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
const dir = await fs.open(path.dirname(file), "r");
|
||||||
|
try { await dir.sync(); } finally { await dir.close(); }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw fail("storage");
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => {});
|
||||||
|
await fs.unlink(temp).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedAuthEndpoint(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.origin === ISSUER && !url.username && !url.password && !url.search && !url.hash;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch, onChange = () => {}, timeoutMs = 180000 }) {
|
||||||
|
let state;
|
||||||
|
let init;
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
let epoch = 0;
|
||||||
|
let revision = 0;
|
||||||
|
let authorityRevision = 0;
|
||||||
|
let catalogSequence = 0;
|
||||||
|
let storageDenied = false;
|
||||||
|
let secureAvailable = true;
|
||||||
|
const stamp = () => ({ epoch, authorityRevision, revision });
|
||||||
|
let backgroundIntent;
|
||||||
|
let pending;
|
||||||
|
let catalog = [];
|
||||||
|
let catalogError = null;
|
||||||
|
let lastError = null;
|
||||||
|
let closed = false;
|
||||||
|
let paused = false;
|
||||||
|
let suspended = false;
|
||||||
|
let retryRegistration;
|
||||||
|
let transportAbort = new AbortController();
|
||||||
|
let backgroundAbort = new AbortController();
|
||||||
|
const serial = (fn) => {
|
||||||
|
const next = queue.then(fn, fn);
|
||||||
|
queue = next.catch(() => {});
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
const active = () => !storageDenied && !suspended && !closed && !paused ? state?.registrations.find((r) => r.id === state.activeId && r.tokens && !r.reconnectRequired) : undefined;
|
||||||
|
// These fields are validated/verified before storage. Email is never identity.
|
||||||
|
const sameAccount = (a, b) => a.issuer === b.issuer && a.subject === b.subject;
|
||||||
|
function accountGroups() {
|
||||||
|
const groups = [];
|
||||||
|
for (const record of state?.registrations || []) {
|
||||||
|
const group = groups.find((members) => sameAccount(members[0], record));
|
||||||
|
if (group) group.push(record); else groups.push([record]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
const backgroundAllowed = () => state?.allowBackgroundUse !== false && backgroundIntent?.enabled !== false;
|
||||||
|
const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); };
|
||||||
|
function stop(backgroundOnly = false) {
|
||||||
|
if (backgroundOnly) {
|
||||||
|
backgroundAbort.abort();
|
||||||
|
backgroundAbort = new AbortController();
|
||||||
|
notify("background");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suspended = true;
|
||||||
|
epoch++;
|
||||||
|
authorityRevision++;
|
||||||
|
catalogSequence++;
|
||||||
|
transportAbort.abort();
|
||||||
|
transportAbort = new AbortController();
|
||||||
|
catalog = [];
|
||||||
|
catalogError = null;
|
||||||
|
notify("all");
|
||||||
|
}
|
||||||
|
async function ready() {
|
||||||
|
if (closed) throw fail("disconnected");
|
||||||
|
if (!init) init = (async () => {
|
||||||
|
const loaded = await store.read();
|
||||||
|
state = loaded ? validateState(loaded) : null;
|
||||||
|
if (!state) {
|
||||||
|
const first = { version: 1, hostId: `urn:uuid:${crypto.randomUUID()}`, activeId: null, registrations: [] };
|
||||||
|
await store.write(first);
|
||||||
|
state = first;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
try { await init; checkSecure(); } catch (err) { denyStorage(err); throw err; }
|
||||||
|
}
|
||||||
|
function denyStorage(err) {
|
||||||
|
secureAvailable = false;
|
||||||
|
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
|
||||||
|
}
|
||||||
|
function checkSecure() {
|
||||||
|
try { store.secure(); secureAvailable = true; } catch (err) { denyStorage(err); throw err; }
|
||||||
|
}
|
||||||
|
async function save(next) {
|
||||||
|
try { checkSecure(); validateState(next); await store.write(next); checkSecure(); } catch (err) {
|
||||||
|
// A rotated token may already have invalidated its predecessor. Fail closed.
|
||||||
|
denyStorage(err);
|
||||||
|
lastError = publicError(err);
|
||||||
|
notify();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
function snapshot() {
|
||||||
|
return {
|
||||||
|
available: !!state && secureAvailable && !closed && !paused,
|
||||||
|
pending: !!pending,
|
||||||
|
allowBackgroundUse: backgroundAllowed(),
|
||||||
|
activeId: state?.activeId ?? null,
|
||||||
|
accounts: accountGroups().map((members) => {
|
||||||
|
// Preserve the actual active registration ID at the private transport
|
||||||
|
// boundary; inactive groups prefer a usable registration, then a
|
||||||
|
// remembered one. No credential/client records are merged or deleted.
|
||||||
|
const r = members.find((r) => r.id === state.activeId) ?? members.find((r) => !r.reconnectRequired && planScopes(r.tokens)) ?? members[0];
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
email: r.email || "ChatGPT account",
|
||||||
|
registration: r.clientId,
|
||||||
|
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
|
||||||
|
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
error: lastError,
|
||||||
|
catalogError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function status() {
|
||||||
|
try { await ready(); } catch (err) { return { ...snapshot(), available: false, error: publicError(err) }; }
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
async function json(url, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await request(url, { ...options, redirect: "error", signal: options.signal ?? AbortSignal.timeout(15000) });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
const oauthCode = typeof body.error === "string" ? body.error : body.error?.code;
|
||||||
|
throw Object.assign(fail(oauthCode === "invalid_grant" || response.status === 401 || response.status === 403 ? "expired" : "network"), { oauthCode });
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (err) { throw err.code ? err : fail("network"); }
|
||||||
|
}
|
||||||
|
async function identity(idToken, clientId, nonce, expectedSubject) {
|
||||||
|
try {
|
||||||
|
const { jwtVerify, createLocalJWKSet } = await import("jose");
|
||||||
|
// Fixed, documented JWKS origin; never follow a discovery-supplied URL.
|
||||||
|
const keys = await json(JWKS);
|
||||||
|
const { payload } = await jwtVerify(idToken, createLocalJWKSet(keys), {
|
||||||
|
issuer: ISSUER, audience: clientId, algorithms: ["RS256"],
|
||||||
|
requiredClaims: ["sub", "exp", "iat"], clockTolerance: 5,
|
||||||
|
});
|
||||||
|
if (typeof payload.sub !== "string" || !payload.sub || (nonce !== undefined && payload.nonce !== nonce) || (expectedSubject && payload.sub !== expectedSubject)) throw fail("identity");
|
||||||
|
if ((payload.azp !== undefined && payload.azp !== clientId) || (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId)) throw fail("identity");
|
||||||
|
return { subject: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined };
|
||||||
|
} catch { throw fail("identity"); }
|
||||||
|
}
|
||||||
|
function tokenSet(body, previous) {
|
||||||
|
const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/) : previous?.scopes;
|
||||||
|
if (!scopes || SCOPES.some((scope) => !scopes.includes(scope))) {
|
||||||
|
// Lack of entitlement is not lack of revocable material.
|
||||||
|
return { idToken: body.id_token ?? previous?.idToken, scopes: scopes ?? [], expiresAt: 0,
|
||||||
|
...(nonempty(body.refresh_token) ? { refreshToken: body.refresh_token } : previous?.refreshToken ? { refreshToken: previous.refreshToken } : {}) };
|
||||||
|
}
|
||||||
|
if (typeof body.access_token !== "string" || !body.access_token || body.access_token.startsWith("sk-") || body.token_type?.toLowerCase() !== "bearer" || !Number.isFinite(body.expires_in) || body.expires_in <= 0) throw fail("identity");
|
||||||
|
const refreshToken = body.refresh_token ?? previous?.refreshToken;
|
||||||
|
if (!nonempty(refreshToken)) throw fail("identity");
|
||||||
|
return { accessToken: body.access_token, refreshToken, idToken: body.id_token ?? previous?.idToken, scopes, expiresAt: Date.now() + body.expires_in * 1000 };
|
||||||
|
}
|
||||||
|
async function grant(fields, signal) {
|
||||||
|
return json(TOKEN, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: new URLSearchParams({ ...fields, resource: RESOURCE }), signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(accountId) {
|
||||||
|
await ready();
|
||||||
|
if (pending) throw fail("pending");
|
||||||
|
if (paused) throw fail("disconnected");
|
||||||
|
const selected = accountId ? state.registrations.find((r) => r.id === accountId) : retryRegistration;
|
||||||
|
if (accountId && !selected) throw fail("identity");
|
||||||
|
const attemptEpoch = epoch;
|
||||||
|
const verifier = random();
|
||||||
|
const nonce = random();
|
||||||
|
const expectedState = random();
|
||||||
|
const abort = new AbortController();
|
||||||
|
let finish;
|
||||||
|
const completion = new Promise((resolve) => { finish = resolve; });
|
||||||
|
const listener = http.createServer();
|
||||||
|
let timer;
|
||||||
|
let consumed = false;
|
||||||
|
const cleanup = () => { clearTimeout(timer); listener.close(); listener.closeAllConnections(); };
|
||||||
|
const cancel = (code = "cancelled") => {
|
||||||
|
abort.abort();
|
||||||
|
cleanup();
|
||||||
|
if (pending?.abort === abort) pending = null;
|
||||||
|
lastError = publicError(fail(code));
|
||||||
|
finish();
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
pending = { abort, cancel };
|
||||||
|
lastError = null;
|
||||||
|
notify();
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
listener.once("error", reject);
|
||||||
|
listener.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const redirectUri = `http://127.0.0.1:${listener.address().port}/auth/callback`;
|
||||||
|
listener.on("request", async (req, res) => {
|
||||||
|
let callback;
|
||||||
|
try { callback = new URL(req.url || "/", redirectUri); }
|
||||||
|
catch { res.writeHead(400).end(); return; }
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("Content-Security-Policy", "default-src 'none'");
|
||||||
|
res.setHeader("Referrer-Policy", "no-referrer");
|
||||||
|
if (req.method !== "GET" || callback.origin !== new URL(redirectUri).origin || callback.pathname !== "/auth/callback" || req.headers.host !== new URL(redirectUri).host || consumed) { res.writeHead(404).end(); return; }
|
||||||
|
const params = callback.searchParams;
|
||||||
|
// Reject duplicate parameters and unsolicited callbacks without consuming a valid pending attempt.
|
||||||
|
if (["state", "code", "client_id", "error"].some((key) => params.getAll(key).length > 1) || params.get("state") !== expectedState) { res.writeHead(400).end("Sign-in could not be verified."); return; }
|
||||||
|
consumed = true;
|
||||||
|
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("Return to LearnHarness to view the sign-in result. You may close this tab.");
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
if (params.has("error")) throw fail(params.get("error") === "access_denied" ? "cancelled" : "identity");
|
||||||
|
const clientId = params.get("client_id") || selected?.clientId;
|
||||||
|
if (!clientId || clientId === "dynamic_agent_client" || clientId.length > 512 || /[\s\x00-\x1f]/.test(clientId) || (selected && clientId !== selected.clientId) || !params.get("code")) throw fail("identity");
|
||||||
|
let body;
|
||||||
|
try { body = await grant({ grant_type: "authorization_code", client_id: clientId, code: params.get("code"), code_verifier: verifier, redirect_uri: redirectUri }, AbortSignal.any([abort.signal, AbortSignal.timeout(15000)])); }
|
||||||
|
catch (err) {
|
||||||
|
if (err.oauthCode === "invalid_grant" && !selected?.subject) retryRegistration = { clientId };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (typeof body.id_token !== "string") throw fail("identity");
|
||||||
|
const verified = await identity(body.id_token, clientId, nonce, selected?.subject);
|
||||||
|
const tokens = tokenSet(body);
|
||||||
|
await serial(async () => {
|
||||||
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled");
|
||||||
|
const existing = state.registrations.find((r) => r.clientId === clientId);
|
||||||
|
if (existing && existing.subject !== verified.subject) throw fail("identity");
|
||||||
|
const record = { id: existing?.id ?? crypto.randomUUID(), clientId, issuer: ISSUER, ...verified, tokens };
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
|
||||||
|
next.activeId = record.id;
|
||||||
|
const previous = structuredClone(state);
|
||||||
|
await save(next); // Active identity changes only AFTER validation and durable encrypted persistence.
|
||||||
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
|
||||||
|
await save(previous);
|
||||||
|
throw fail("cancelled");
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
storageDenied = false;
|
||||||
|
suspended = false;
|
||||||
|
retryRegistration = undefined;
|
||||||
|
});
|
||||||
|
lastError = planScopes(tokens) ? null : publicError(fail("scope"));
|
||||||
|
} catch (err) { if (!abort.signal.aborted) lastError = publicError(err); }
|
||||||
|
finally { if (pending?.abort === abort) pending = null; finish(); notify(); }
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => cancel("timeout"), timeoutMs);
|
||||||
|
const url = new URL(AUTHORIZE);
|
||||||
|
url.search = new URLSearchParams({
|
||||||
|
client_id: selected?.clientId ?? "dynamic_agent_client",
|
||||||
|
...(selected ? {} : { agent_name_hint: "LearnHarness" }),
|
||||||
|
ext_agent_host_id: state.hostId,
|
||||||
|
...(selected?.tokens?.idToken ? { id_token_hint: selected.tokens.idToken } : {}),
|
||||||
|
...(selected?.email ? { login_hint: selected.email } : {}),
|
||||||
|
response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), resource: RESOURCE,
|
||||||
|
state: expectedState, nonce, code_challenge_method: "S256",
|
||||||
|
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
|
||||||
|
}).toString();
|
||||||
|
await openExternal(url.toString()); // Trusted main only. Never return this URL to the renderer.
|
||||||
|
} catch (err) { cancel(err.code || "network"); }
|
||||||
|
void completion;
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
||||||
|
|
||||||
|
function authorized(background, expected, capturedEpoch) {
|
||||||
|
checkSecure();
|
||||||
|
const account = active();
|
||||||
|
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
||||||
|
if (!planScopes(account.tokens)) throw fail("scope");
|
||||||
|
if (expected && (expected.expectedAccountId !== account.id || expected.expectedAuthorityRevision !== authorityRevision || !catalog.some((m) => m.id === expected.selectedModelId))) throw fail("catalog");
|
||||||
|
if (background && !backgroundAllowed()) throw fail("background");
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
async function credential(background, expected) {
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const requestEpoch = epoch;
|
||||||
|
const account = authorized(background, expected);
|
||||||
|
if (account.tokens.expiresAt <= Date.now() + 60000) {
|
||||||
|
const refreshEpoch = epoch;
|
||||||
|
const signal = AbortSignal.any([transportAbort.signal, ...(background ? [backgroundAbort.signal] : []), AbortSignal.timeout(15000)]);
|
||||||
|
let body;
|
||||||
|
try { body = await grant({ grant_type: "refresh_token", client_id: account.clientId, refresh_token: account.tokens.refreshToken }, signal); }
|
||||||
|
catch (err) {
|
||||||
|
if (err.oauthCode === "invalid_grant") {
|
||||||
|
const next = structuredClone(state);
|
||||||
|
delete next.registrations.find((r) => r.id === account.id).tokens;
|
||||||
|
next.activeId = null;
|
||||||
|
await save(next);
|
||||||
|
stop();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let tokens;
|
||||||
|
try {
|
||||||
|
if (body.id_token !== undefined) await identity(body.id_token, account.clientId, undefined, account.subject);
|
||||||
|
tokens = tokenSet(body, account.tokens);
|
||||||
|
} catch (err) {
|
||||||
|
// The fixed token endpoint may have rotated already. Preserve only
|
||||||
|
// verified identity and a single likely-latest revocation handle.
|
||||||
|
stop();
|
||||||
|
const restricted = structuredClone(state);
|
||||||
|
const record = restricted.registrations.find((r) => r.id === account.id);
|
||||||
|
record.reconnectRequired = true;
|
||||||
|
if (nonempty(body.refresh_token)) record.tokens.refreshToken = body.refresh_token;
|
||||||
|
await save(restricted);
|
||||||
|
lastError = publicError(fail("identity")); notify();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// A successful remote rotation must be durably retained even when
|
||||||
|
// logout, account selection, consent revocation, or pause denies use.
|
||||||
|
// Those serialized operations will revoke/clear the LATEST token.
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.registrations.find((r) => r.id === account.id).tokens = tokens;
|
||||||
|
await save(next);
|
||||||
|
if (closed || refreshEpoch !== epoch || signal.aborted) throw fail("disconnected");
|
||||||
|
}
|
||||||
|
const current = authorized(background, expected, requestEpoch);
|
||||||
|
return { accessToken: current.tokens.accessToken, accountId: current.id, ...stamp() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function lease(background, expected) {
|
||||||
|
if (typeof background !== "boolean" || !expected || !nonempty(expected.expectedAccountId) || !Number.isSafeInteger(expected.expectedAuthorityRevision) || !nonempty(expected.selectedModelId)) throw fail("catalog");
|
||||||
|
return credential(background, expected);
|
||||||
|
}
|
||||||
|
const modelValue = () => ({ ...stamp(), status: snapshot(), accountId: active()?.id ?? null, models: catalog });
|
||||||
|
async function models() {
|
||||||
|
const sequence = ++catalogSequence;
|
||||||
|
try {
|
||||||
|
await ready();
|
||||||
|
if (!active()) return modelValue();
|
||||||
|
// Discovery is identity/scope authorization, not an invented model lease.
|
||||||
|
const token = await credential(false);
|
||||||
|
const body = await json(`${RESOURCE}/models`, { headers: { Authorization: `Bearer ${token.accessToken}` }, signal: AbortSignal.any([transportAbort.signal, AbortSignal.timeout(15000)]) });
|
||||||
|
authorized(false, undefined, token.epoch);
|
||||||
|
if (sequence !== catalogSequence || active()?.id !== token.accountId || !Array.isArray(body.models)) throw fail("catalog");
|
||||||
|
const visible = body.models.filter((m) => m && m.visibility === "list" && nonempty(m.slug) && typeof m.display_name === "string").map((m) => ({ id: m.slug, name: m.display_name }));
|
||||||
|
const changed = JSON.stringify(visible) !== JSON.stringify(catalog);
|
||||||
|
catalog = visible;
|
||||||
|
if (changed) authorityRevision++;
|
||||||
|
const hadError = catalogError !== null; catalogError = null;
|
||||||
|
if (changed || hadError) notify();
|
||||||
|
return modelValue();
|
||||||
|
} catch {
|
||||||
|
if (sequence === catalogSequence) {
|
||||||
|
if (catalog.length) { catalog = []; authorityRevision++; }
|
||||||
|
catalogError = publicError(fail("catalog")); notify();
|
||||||
|
}
|
||||||
|
return modelValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function select(id) {
|
||||||
|
pending?.cancel();
|
||||||
|
stop();
|
||||||
|
const selectionIntent = epoch;
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const account = state.registrations.find((r) => r.id === id);
|
||||||
|
if (!account?.tokens) throw fail("disconnected");
|
||||||
|
await save({ ...state, activeId: id });
|
||||||
|
if (epoch !== selectionIntent || closed || paused || storageDenied) throw fail("disconnected");
|
||||||
|
suspended = false;
|
||||||
|
lastError = null;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function consent(enabled) {
|
||||||
|
if (typeof enabled !== "boolean") throw fail("identity");
|
||||||
|
// One app-owned intent, independent of account/registration lifetime.
|
||||||
|
const intent = { enabled: false };
|
||||||
|
backgroundIntent = intent; // ON cannot grant until its durable save.
|
||||||
|
if (!enabled) stop(true); // Revoke all ChatGPT background streams only.
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
await save({ ...structuredClone(state), allowBackgroundUse: enabled });
|
||||||
|
if (backgroundIntent === intent && enabled) backgroundIntent = undefined;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function revoke(account) {
|
||||||
|
let revoked = false;
|
||||||
|
try {
|
||||||
|
if (account.tokens?.refreshToken) {
|
||||||
|
const config = await json(`${ISSUER}/.well-known/openid-configuration`);
|
||||||
|
if (config.issuer !== ISSUER || !allowedAuthEndpoint(config.revocation_endpoint)) throw fail("identity");
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
if (attempt) await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
try {
|
||||||
|
const response = await request(config.revocation_endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(5000), headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: account.tokens.refreshToken, token_type_hint: "refresh_token", client_id: account.clientId }) });
|
||||||
|
revoked = response.status === 200;
|
||||||
|
if (revoked || response.status < 500) break;
|
||||||
|
} catch { /* retry only while the refresh token is still in memory */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* local action is mandatory even when revocation is unavailable */ }
|
||||||
|
return revoked;
|
||||||
|
}
|
||||||
|
async function disconnect(id) {
|
||||||
|
// Explicit local forgetting: a visible account represents every retained
|
||||||
|
// registration with this exact verified issuer/subject, never its email.
|
||||||
|
pending?.cancel();
|
||||||
|
const initial = state?.registrations.find((r) => r.id === id);
|
||||||
|
let stopped = !!initial && state.registrations.some((r) => r.id === state.activeId && sameAccount(r, initial));
|
||||||
|
if (stopped) stop();
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const target = state.registrations.find((r) => r.id === id);
|
||||||
|
if (!target) throw fail("identity");
|
||||||
|
const members = state.registrations.filter((r) => sameAccount(r, target));
|
||||||
|
const activeGroup = members.some((r) => r.id === state.activeId);
|
||||||
|
if (activeGroup && !stopped) { stop(); stopped = true; }
|
||||||
|
const disconnectEpoch = epoch;
|
||||||
|
let revoked = true;
|
||||||
|
for (const member of members) { const confirmed = await revoke(member); revoked = confirmed && revoked; }
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.registrations = next.registrations.filter((r) => !sameAccount(r, target));
|
||||||
|
if (activeGroup) next.activeId = null; // Never silently select another account.
|
||||||
|
await save(next);
|
||||||
|
if (members.some((r) => r.clientId === retryRegistration?.clientId)) retryRegistration = undefined;
|
||||||
|
if (stopped && !closed && epoch === disconnectEpoch) suspended = false;
|
||||||
|
lastError = revoked ? null : REVOCATION_WARNING;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function logout(id) {
|
||||||
|
pending?.cancel();
|
||||||
|
stop();
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const account = state.registrations.find((r) => r.id === id);
|
||||||
|
if (!account) throw fail("identity");
|
||||||
|
const revoked = await revoke(account);
|
||||||
|
const next = structuredClone(state);
|
||||||
|
delete next.registrations.find((r) => r.id === id).tokens;
|
||||||
|
if (next.activeId === id) next.activeId = null;
|
||||||
|
const logoutEpoch = epoch;
|
||||||
|
await save(next);
|
||||||
|
if (!closed && epoch === logoutEpoch) suspended = false;
|
||||||
|
lastError = revoked ? null : REVOCATION_WARNING;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function close() { closed = true; pending?.cancel(); stop(); }
|
||||||
|
function pause() { paused = true; pending?.cancel(); stop(); notify(); }
|
||||||
|
function resume() { paused = false; if (!storageDenied) suspended = false; notify(); }
|
||||||
|
return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const HEADER = "x-learnharness-desktop";
|
||||||
|
|
||||||
|
/** Called for EVERY outgoing request, so redirects cannot carry the secret
|
||||||
|
* to another origin. Never expose this function or its secret to preload. */
|
||||||
|
function desktopAPIHeaders(details, origin, secret, windows) {
|
||||||
|
const headers = { ...details.requestHeaders };
|
||||||
|
for (const name of Object.keys(headers)) if (name.toLowerCase() === HEADER) delete headers[name];
|
||||||
|
if (!secret) return headers;
|
||||||
|
try {
|
||||||
|
const target = new URL(details.url);
|
||||||
|
if (target.origin !== origin || target.username || target.password || !(target.pathname === "/api" || target.pathname.startsWith("/api/"))) return headers;
|
||||||
|
const win = windows.find((candidate) => !candidate.isDestroyed() && candidate.webContents.id === details.webContentsId);
|
||||||
|
if (!win || win.webContents.isDestroyed()) return headers;
|
||||||
|
const contents = win.webContents;
|
||||||
|
if (details.webContents && details.webContents !== contents) return headers;
|
||||||
|
if (!details.frame || details.frame !== contents.mainFrame) return headers;
|
||||||
|
if (new URL(contents.mainFrame.url).origin !== origin) return headers;
|
||||||
|
if (details.initiatorOrigin !== undefined && details.initiatorOrigin !== origin) return headers;
|
||||||
|
headers[HEADER] = secret;
|
||||||
|
} catch { /* Missing/destroyed/navigated frames fail closed. */ }
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
module.exports = { desktopAPIHeaders, HEADER };
|
||||||
+44
-3
@@ -24,7 +24,11 @@
|
|||||||
* would shadow every settings-page key change until restart.
|
* would shadow every settings-page key change until restart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog, safeStorage } = require("electron");
|
||||||
|
const { createChatGPT, encryptedStore } = require("./chatgpt.cjs");
|
||||||
|
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||||
|
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||||
|
const { randomBytes } = require("node:crypto");
|
||||||
const { spawn } = require("node:child_process");
|
const { spawn } = require("node:child_process");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
@@ -128,6 +132,11 @@ if (process.argv.includes("--browser-service")) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serverProcess = null;
|
let serverProcess = null;
|
||||||
|
let chatgpt = null;
|
||||||
|
let chatgptChild = null;
|
||||||
|
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||||
|
let desktopAPISecret = null;
|
||||||
|
const trustedAppWindows = new Set();
|
||||||
|
|
||||||
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
||||||
// in an event handler): the window + child server are the product, and
|
// in an event handler): the window + child server are the product, and
|
||||||
@@ -201,17 +210,23 @@ function startServer(port) {
|
|||||||
// (no env injection anymore: a spawn-time copy would shadow every
|
// (no env injection anymore: a spawn-time copy would shadow every
|
||||||
// settings-page key change until restart; env wins over file).
|
// settings-page key change until restart; env wins over file).
|
||||||
LH_AUTH_FILE: AUTH_FILE(),
|
LH_AUTH_FILE: AUTH_FILE(),
|
||||||
|
LH_DESKTOP_SERVER: "1",
|
||||||
|
LH_DESKTOP_API_SECRET: desktopAPISecret,
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
serverProcess = child;
|
serverProcess = child;
|
||||||
|
chatgptChild = attachChatGPTChild(child, chatgpt);
|
||||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||||
child.stdout.on("data", fwd);
|
child.stdout.on("data", fwd);
|
||||||
child.stderr.on("data", fwd);
|
child.stderr.on("data", fwd);
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
log(`server exited (code=${code} signal=${signal})`);
|
log(`server exited (code=${code} signal=${signal})`);
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
|
chatgptChild?.dispose();
|
||||||
|
chatgptChild = null;
|
||||||
|
chatgpt?.close();
|
||||||
});
|
});
|
||||||
resolve(child);
|
resolve(child);
|
||||||
});
|
});
|
||||||
@@ -235,6 +250,7 @@ function waitUntilUp(url, tries = 150) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function killServer() {
|
function killServer() {
|
||||||
|
chatgpt?.close();
|
||||||
if (!serverProcess) return;
|
if (!serverProcess) return;
|
||||||
const child = serverProcess;
|
const child = serverProcess;
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
@@ -306,6 +322,8 @@ function createWindow(url) {
|
|||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trustedAppWindows.add(win);
|
||||||
|
win.once("closed", () => trustedAppWindows.delete(win));
|
||||||
const allowedOrigin = () => new URL(url).origin;
|
const allowedOrigin = () => new URL(url).origin;
|
||||||
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
|
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
|
||||||
// clipboard-sanitized-write — the copy affordances in the transcript
|
// clipboard-sanitized-write — the copy affordances in the transcript
|
||||||
@@ -546,6 +564,7 @@ function installMenu() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
|
chatgpt?.pause();
|
||||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||||
if (process.platform !== "darwin") app.quit();
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
@@ -563,6 +582,20 @@ async function main() {
|
|||||||
const devUrl = process.env.LH_DEV_URL;
|
const devUrl = process.env.LH_DEV_URL;
|
||||||
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
||||||
if (!devUrl) {
|
if (!devUrl) {
|
||||||
|
desktopAPISecret = randomBytes(32).toString("hex");
|
||||||
|
const ownedOrigin = new URL(url).origin;
|
||||||
|
// All URLs, not only /api: remove the header again on external redirects.
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ["<all_urls>"] }, (details, callback) => {
|
||||||
|
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
||||||
|
});
|
||||||
|
chatgpt = createChatGPT({
|
||||||
|
store: encryptedStore(path.join(app.getPath("userData"), "chatgpt-credentials.enc.json"), safeStorage),
|
||||||
|
openExternal: (target) => shell.openExternal(target),
|
||||||
|
onChange: (value) => {
|
||||||
|
chatgptChild?.send(value);
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
|
||||||
|
},
|
||||||
|
});
|
||||||
log(`starting server: ${serverEntry()}`);
|
log(`starting server: ${serverEntry()}`);
|
||||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||||||
await startServer(new URL(url).port);
|
await startServer(new URL(url).port);
|
||||||
@@ -572,6 +605,14 @@ async function main() {
|
|||||||
log(`dev mode: ${devUrl}`);
|
log(`dev mode: ${devUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renderer controls contain no token/URL/code operation. The separate server
|
||||||
|
// child is the only recipient of credential leases over inherited IPC.
|
||||||
|
ipcMain.handle("lh:chatgpt:control", async (event, command) => {
|
||||||
|
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return { error: "Untrusted desktop request." };
|
||||||
|
if (!chatgpt || !serverProcess?.connected) return { available: false, pending: false, activeId: null, accounts: [], error: "ChatGPT sign-in requires the desktop app's private server connection. Browser-only and externally launched development servers keep API-key connections." };
|
||||||
|
return chatGPTControl(command, chatgpt);
|
||||||
|
});
|
||||||
|
|
||||||
installMenu();
|
installMenu();
|
||||||
|
|
||||||
// Auto-update (Wave C): wire the updater before the window so the
|
// Auto-update (Wave C): wire the updater before the window so the
|
||||||
@@ -597,7 +638,7 @@ async function main() {
|
|||||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||||
});
|
});
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
|
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); createWindow(url); }
|
||||||
});
|
});
|
||||||
createWindow(url);
|
createWindow(url);
|
||||||
log(`window open: ${url}`);
|
log(`window open: ${url}`);
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ const { contextBridge, ipcRenderer } = require("electron");
|
|||||||
|
|
||||||
contextBridge.exposeInMainWorld("lh", {
|
contextBridge.exposeInMainWorld("lh", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
chatgpt: {
|
||||||
|
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
|
||||||
|
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
|
||||||
|
cancel: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "cancel" }),
|
||||||
|
select: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "select", id }),
|
||||||
|
disconnect: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "disconnect", id }),
|
||||||
|
logout: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "logout", id }),
|
||||||
|
consent: (enabled) => ipcRenderer.invoke("lh:chatgpt:control", { op: "consent", enabled }),
|
||||||
|
onChange: (cb) => {
|
||||||
|
const listener = () => cb();
|
||||||
|
ipcRenderer.on("lh:chatgpt:changed", listener);
|
||||||
|
return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
||||||
onZoom: (cb) => {
|
onZoom: (cb) => {
|
||||||
const listener = (_event, factor) => cb(factor);
|
const listener = (_event, factor) => cb(factor);
|
||||||
|
|||||||
+16
-4
@@ -17,7 +17,8 @@ const nextConfig: NextConfig = {
|
|||||||
// into the DMG (the packaged app never reads it: the shell passes
|
// into the DMG (the packaged app never reads it: the shell passes
|
||||||
// LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES
|
// LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES
|
||||||
// to package any standalone still containing it. .env* belt-and-braces
|
// to package any standalone still containing it. .env* belt-and-braces
|
||||||
// the same class (not observed swept, same seam).
|
// the same class. Next also copies loaded bare .env after tracing;
|
||||||
|
// the build-server refusal remains required even with these excludes.
|
||||||
//
|
//
|
||||||
// ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE
|
// ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE
|
||||||
// (empirically established 2026-10-08): adding dir excludes like
|
// (empirically established 2026-10-08): adding dir excludes like
|
||||||
@@ -26,8 +27,11 @@ const nextConfig: NextConfig = {
|
|||||||
// under their dist/ — the traced copy collapsed to a bare package.json
|
// under their dist/ — the traced copy collapsed to a bare package.json
|
||||||
// and the packaged server died on ERR_MODULE_NOT_FOUND; openai/
|
// and the packaged server died on ERR_MODULE_NOT_FOUND; openai/
|
||||||
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
|
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
|
||||||
// Anchored "./"-prefixed forms behave the same. Only the three entries
|
// Anchored "./"-prefixed forms behave the same. **/data/**/* fixes
|
||||||
// below are verified safe (complete externals trace + clean payload).
|
// suffix matching in Next's JavaScript collector, but NOT its native
|
||||||
|
// Turbopack tracer: that still prunes gcp-metadata and its private deps.
|
||||||
|
// Keep the narrow closure workaround below AND the native runtime gate.
|
||||||
|
// Real SDK data/ directories still need their explicit includes too.
|
||||||
// The recursive-DMG payload (repo dist/ swept into the bundle, shipping
|
// The recursive-DMG payload (repo dist/ swept into the bundle, shipping
|
||||||
// the previous build inside the next) is instead prevented by the
|
// the previous build inside the next) is instead prevented by the
|
||||||
// desktop:dist clean step AND targeted post-trace curation in
|
// desktop:dist clean step AND targeted post-trace curation in
|
||||||
@@ -37,7 +41,7 @@ const nextConfig: NextConfig = {
|
|||||||
// stay intact, and the script guards against leaked data/keys.
|
// stay intact, and the script guards against leaked data/keys.
|
||||||
outputFileTracingExcludes: {
|
outputFileTracingExcludes: {
|
||||||
"*": [
|
"*": [
|
||||||
"data/**/*",
|
"**/data/**/*",
|
||||||
"auth.json",
|
"auth.json",
|
||||||
".env*",
|
".env*",
|
||||||
],
|
],
|
||||||
@@ -71,6 +75,14 @@ const nextConfig: NextConfig = {
|
|||||||
"node_modules/@resvg/resvg-js-*/**/*",
|
"node_modules/@resvg/resvg-js-*/**/*",
|
||||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||||
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||||
|
// Next 16.3.2 native Turbopack applies unanchored excludes while
|
||||||
|
// traversing modules; **/data/**/* still prunes gcp-metadata and
|
||||||
|
// its private runtime chain. Includes are applied after traversal.
|
||||||
|
// This is the verified GoogleAuth closure, not a general dep copy;
|
||||||
|
// the offline native Pi gate must catch future SDK closure changes.
|
||||||
|
"node_modules/gcp-metadata/**/*",
|
||||||
|
"node_modules/json-bigint/**/*",
|
||||||
|
"node_modules/bignumber.js/**/*",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
||||||
|
|||||||
Generated
+144
-134
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.1",
|
"version": "0.3.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.1",
|
"version": "0.3.5",
|
||||||
"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.4.0",
|
||||||
"@earendil-works/pi-ai": "1.0.2",
|
"@earendil-works/pi-ai": "1.4.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
"dagre-d3-es": "7.0.14",
|
"dagre-d3-es": "7.0.14",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"katex": "^0.16.47",
|
"jose": "^6.2.12",
|
||||||
|
"katex": "0.18.2",
|
||||||
"linkedom": "^0.18.13",
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
"media-chrome": "^4.19.2",
|
"media-chrome": "^4.19.2",
|
||||||
@@ -145,16 +146,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/core": {
|
"node_modules/@aws-sdk/core": {
|
||||||
"version": "3.978.1",
|
"version": "3.978.5",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.5.tgz",
|
||||||
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
|
"integrity": "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@aws-sdk/xml-builder": "^3.972.41",
|
"@aws-sdk/xml-builder": "^3.972.41",
|
||||||
"@aws/lambda-invoke-store": "^0.3.0",
|
"@aws/lambda-invoke-store": "^0.3.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/signature-v4": "^5.7.3",
|
"@smithy/signature-v4": "^5.7.4",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -164,14 +165,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-env": {
|
"node_modules/@aws-sdk/credential-provider-env": {
|
||||||
"version": "3.972.72",
|
"version": "3.972.76",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz",
|
||||||
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
|
"integrity": "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -180,14 +181,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-http": {
|
"node_modules/@aws-sdk/credential-provider-http": {
|
||||||
"version": "3.972.74",
|
"version": "3.972.78",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz",
|
||||||
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
|
"integrity": "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/fetch-http-handler": "^5.8.0",
|
"@smithy/fetch-http-handler": "^5.8.0",
|
||||||
"@smithy/node-http-handler": "^4.12.1",
|
"@smithy/node-http-handler": "^4.12.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
@@ -198,21 +199,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||||
"version": "3.973.17",
|
"version": "3.973.21",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz",
|
||||||
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
|
"integrity": "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
"@aws-sdk/credential-provider-env": "^3.972.76",
|
||||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
"@aws-sdk/credential-provider-http": "^3.972.78",
|
||||||
"@aws-sdk/credential-provider-login": "^3.972.79",
|
"@aws-sdk/credential-provider-login": "^3.972.83",
|
||||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
"@aws-sdk/credential-provider-process": "^3.972.76",
|
||||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
"@aws-sdk/credential-provider-sso": "^3.973.20",
|
||||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
|
||||||
"@aws-sdk/nested-clients": "^3.997.46",
|
"@aws-sdk/nested-clients": "^3.997.50",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/credential-provider-imds": "^4.5.2",
|
"@smithy/credential-provider-imds": "^4.5.2",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -222,15 +223,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-login": {
|
"node_modules/@aws-sdk/credential-provider-login": {
|
||||||
"version": "3.972.79",
|
"version": "3.972.83",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz",
|
||||||
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
|
"integrity": "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/nested-clients": "^3.997.46",
|
"@aws-sdk/nested-clients": "^3.997.50",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -239,19 +240,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-node": {
|
"node_modules/@aws-sdk/credential-provider-node": {
|
||||||
"version": "3.972.84",
|
"version": "3.972.88",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz",
|
||||||
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
|
"integrity": "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
"@aws-sdk/credential-provider-env": "^3.972.76",
|
||||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
"@aws-sdk/credential-provider-http": "^3.972.78",
|
||||||
"@aws-sdk/credential-provider-ini": "^3.973.17",
|
"@aws-sdk/credential-provider-ini": "^3.973.21",
|
||||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
"@aws-sdk/credential-provider-process": "^3.972.76",
|
||||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
"@aws-sdk/credential-provider-sso": "^3.973.20",
|
||||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/credential-provider-imds": "^4.5.2",
|
"@smithy/credential-provider-imds": "^4.5.2",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -261,14 +262,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-process": {
|
"node_modules/@aws-sdk/credential-provider-process": {
|
||||||
"version": "3.972.72",
|
"version": "3.972.76",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz",
|
||||||
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
|
"integrity": "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -277,16 +278,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||||
"version": "3.973.16",
|
"version": "3.973.20",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz",
|
||||||
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
|
"integrity": "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/nested-clients": "^3.997.46",
|
"@aws-sdk/nested-clients": "^3.997.50",
|
||||||
"@aws-sdk/token-providers": "3.1138.0",
|
"@aws-sdk/token-providers": "3.1155.0",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -295,15 +296,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
||||||
"version": "3.1138.0",
|
"version": "3.1155.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz",
|
||||||
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
|
"integrity": "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/nested-clients": "^3.997.46",
|
"@aws-sdk/nested-clients": "^3.997.50",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -312,15 +313,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||||
"version": "3.972.78",
|
"version": "3.972.82",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz",
|
||||||
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
|
"integrity": "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/nested-clients": "^3.997.46",
|
"@aws-sdk/nested-clients": "^3.997.50",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -329,13 +330,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/eventstream-handler-node": {
|
"node_modules/@aws-sdk/eventstream-handler-node": {
|
||||||
"version": "3.972.35",
|
"version": "3.972.38",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.38.tgz",
|
||||||
"integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==",
|
"integrity": "sha512-K7vPKNstDlavHFZgbmcFdJOFIyowsevjddycRziRBbtXH/D9z+i9juCnH17liwSX9T8fJiDQzlEYkRtxkR+1vA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -344,13 +345,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-eventstream": {
|
"node_modules/@aws-sdk/middleware-eventstream": {
|
||||||
"version": "3.972.30",
|
"version": "3.972.33",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.33.tgz",
|
||||||
"integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==",
|
"integrity": "sha512-8QUt39D/dk5tdV7rLvIfMMI/ilQff4zth7mkMKpVOLM+M5AQ9W3nPxFVuHQANevmVWi4T5MlxqfKV4Q2drYeBA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -359,16 +360,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-websocket": {
|
"node_modules/@aws-sdk/middleware-websocket": {
|
||||||
"version": "3.972.54",
|
"version": "3.972.58",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.58.tgz",
|
||||||
"integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==",
|
"integrity": "sha512-/BvocdloKozx/zFr4benH+9J1ywB/2dYFHPWNUdo0CBCe00gTm0HPflOq8MH6cuCTbnywSrscGq0/LheN59teQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/fetch-http-handler": "^5.8.0",
|
"@smithy/fetch-http-handler": "^5.8.0",
|
||||||
"@smithy/signature-v4": "^5.7.3",
|
"@smithy/signature-v4": "^5.7.4",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -377,15 +378,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/nested-clients": {
|
"node_modules/@aws-sdk/nested-clients": {
|
||||||
"version": "3.997.46",
|
"version": "3.997.50",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz",
|
||||||
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
|
"integrity": "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "^3.978.1",
|
"@aws-sdk/core": "^3.978.5",
|
||||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
"@aws-sdk/signature-v4-multi-region": "^3.996.49",
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/core": "^3.35.0",
|
"@smithy/core": "^3.36.1",
|
||||||
"@smithy/fetch-http-handler": "^5.8.0",
|
"@smithy/fetch-http-handler": "^5.8.0",
|
||||||
"@smithy/node-http-handler": "^4.12.1",
|
"@smithy/node-http-handler": "^4.12.1",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
@@ -396,13 +397,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
"version": "3.996.47",
|
"version": "3.996.49",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz",
|
||||||
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
|
"integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "^3.974.6",
|
"@aws-sdk/types": "^3.975.0",
|
||||||
"@smithy/signature-v4": "^5.7.3",
|
"@smithy/signature-v4": "^5.7.4",
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
@@ -428,9 +429,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/types": {
|
"node_modules/@aws-sdk/types": {
|
||||||
"version": "3.974.6",
|
"version": "3.975.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz",
|
||||||
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
|
"integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
@@ -463,9 +464,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.10",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -484,12 +485,12 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "1.0.2",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz",
|
||||||
"integrity": "sha512-VRfewY1R5mbedJzlm01PxdCmqU8QrSXjXXhV50bp6rvVuT8YYQv1inTXRY4YzssmGAYpkC2rXipJKP5BpBFXew==",
|
"integrity": "sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^1.0.2",
|
"@earendil-works/pi-ai": "1.4.0",
|
||||||
"typebox": "1.3.27"
|
"typebox": "1.3.27"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -497,14 +498,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "1.0.2",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz",
|
||||||
"integrity": "sha512-JP59xGlSAQ/HhQ6EHN5qmneBlWfDyu6FPPryAvAaSo8A38ubAsEpS9YisCM5Jbbns0srrDffLZonE0OXorHI5A==",
|
"integrity": "sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.129.0",
|
"@anthropic-ai/sdk": "0.129.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||||
"@earendil-works/pi-telemetry": "^1.0.2",
|
"@earendil-works/pi-telemetry": "1.4.0",
|
||||||
"@google/genai": "2.21.0",
|
"@google/genai": "2.21.0",
|
||||||
"@smithy/node-http-handler": "4.12.1",
|
"@smithy/node-http-handler": "4.12.1",
|
||||||
"http-proxy-agent": "9.1.0",
|
"http-proxy-agent": "9.1.0",
|
||||||
@@ -558,9 +559,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-telemetry": {
|
"node_modules/@earendil-works/pi-telemetry": {
|
||||||
"version": "1.0.2",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz",
|
||||||
"integrity": "sha512-Ev5h9TE8nEXHCfRKhWj0qpUFhQgVqNteS95vsM2KNGRGm533vOO2ruDPaEwImBKPMBKXy4omz3D6V2cm41gLOQ==",
|
"integrity": "sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
@@ -4727,9 +4728,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/core": {
|
"node_modules/@smithy/core": {
|
||||||
"version": "3.35.1",
|
"version": "3.36.2",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.2.tgz",
|
||||||
"integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==",
|
"integrity": "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.19.0",
|
"@smithy/types": "^4.19.0",
|
||||||
@@ -11655,6 +11656,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||||
|
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
@@ -11775,9 +11785,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/katex": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.47",
|
"version": "0.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz",
|
||||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
"integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://opencollective.com/katex",
|
"https://opencollective.com/katex",
|
||||||
"https://github.com/sponsors/katex"
|
"https://github.com/sponsors/katex"
|
||||||
|
|||||||
+9
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.1",
|
"version": "0.3.5",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Amos Blomqvist",
|
"name": "Amos Blomqvist",
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
|
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
|
||||||
"dev:fresh": "bash scripts/dev-fresh.sh",
|
"dev:fresh": "bash scripts/dev-fresh.sh",
|
||||||
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
|
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.0.2",
|
"@earendil-works/pi-agent-core": "1.4.0",
|
||||||
"@earendil-works/pi-ai": "1.0.2",
|
"@earendil-works/pi-ai": "1.4.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
@@ -46,7 +46,8 @@
|
|||||||
"dagre-d3-es": "7.0.14",
|
"dagre-d3-es": "7.0.14",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"katex": "^0.16.47",
|
"jose": "^6.2.12",
|
||||||
|
"katex": "0.18.2",
|
||||||
"linkedom": "^0.18.13",
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
"media-chrome": "^4.19.2",
|
"media-chrome": "^4.19.2",
|
||||||
@@ -70,6 +71,9 @@
|
|||||||
"use-stick-to-bottom": "^1.1.6",
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"katex": "$katex"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/eslint-plugin-next": "^16.3.3",
|
"@next/eslint-plugin-next": "^16.3.3",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { desktopAPIAuthorized } from "./src/server/desktop-access.ts";
|
||||||
|
|
||||||
|
/** Applies to every API method, including SSE and spending/settings routes.
|
||||||
|
* No public callback or worker bypass: internal jobs call server functions,
|
||||||
|
* and any legitimate self-HTTP request must carry the private capability. */
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
if (!desktopAPIAuthorized(request.headers)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Desktop API access requires the trusted LearnHarness window." },
|
||||||
|
{ status: 403, headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = { matcher: "/api/:path*" };
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="ChatGPT logo — original vector">
|
||||||
|
<path id="Vector" d="M11.8102 19.1625C11.2327 19.1625 10.6841 19.0528 10.1643 18.8334C9.64459 18.6139 9.18259 18.3078 8.77834 17.9151C8.33944 18.0653 7.88322 18.1404 7.40967 18.1404C6.63582 18.1404 5.91972 17.9498 5.26137 17.5686C4.60302 17.1875 4.07172 16.6677 3.66747 16.0094C3.27477 15.351 3.07842 14.6176 3.07842 13.8091C3.07842 13.4742 3.12462 13.1103 3.21702 12.7176C2.75502 12.2903 2.39697 11.7994 2.14287 11.245C1.88877 10.679 1.76172 10.09 1.76172 9.47785C1.76172 8.85415 1.89454 8.25355 2.16019 7.67605C2.42584 7.09855 2.79544 6.6019 3.26899 6.1861C3.75409 5.75875 4.31427 5.46422 4.94952 5.30252C5.07657 4.64417 5.34222 4.05512 5.74647 3.53537C6.16227 3.00407 6.67047 2.58827 7.27107 2.28797C7.87167 1.98767 8.51269 1.83752 9.19414 1.83752C9.77164 1.83752 10.3203 1.94725 10.84 2.1667C11.3598 2.38615 11.8218 2.69222 12.226 3.08492C12.6649 2.93477 13.1211 2.8597 13.5947 2.8597C14.3685 2.8597 15.0846 3.05027 15.743 3.43142C16.4013 3.81257 16.9269 4.33232 17.3196 4.99067C17.7238 5.64902 17.9259 6.38245 17.9259 7.19095C17.9259 7.5259 17.8797 7.88973 17.7873 8.28242C18.2493 8.70977 18.6074 9.20643 18.8615 9.77238C19.1156 10.3268 19.2426 10.91 19.2426 11.5222C19.2426 12.1459 19.1098 12.7465 18.8442 13.324C18.5785 13.9015 18.2031 14.4039 17.718 14.8313C17.2445 15.2471 16.6901 15.5358 16.0548 15.6975C15.9278 16.3559 15.6564 16.9449 15.2406 17.4647C14.8363 17.996 14.3339 18.4118 13.7333 18.7121C13.1327 19.0124 12.4917 19.1625 11.8102 19.1625ZM7.53094 16.9969C8.10844 16.9969 8.61087 16.8756 9.03822 16.6331L12.2953 14.762C12.4108 14.6811 12.4686 14.5714 12.4686 14.4328V12.9429L8.27592 15.351C8.02182 15.5012 7.76772 15.5012 7.51362 15.351L4.23919 13.4626C4.23919 13.4973 4.23342 13.5377 4.22187 13.5839C4.22187 13.6301 4.22187 13.6994 4.22187 13.7918C4.22187 14.3808 4.36047 14.9237 4.63767 15.4203C4.92642 15.9054 5.32489 16.2866 5.83309 16.5638C6.34129 16.8525 6.90724 16.9969 7.53094 16.9969ZM7.70419 14.1729C7.77349 14.2076 7.83702 14.2249 7.89477 14.2249C7.95252 14.2249 8.01027 14.2076 8.06802 14.1729L9.36739 13.4279L5.19207 11.0024C4.93797 10.8523 4.81092 10.6271 4.81092 10.3268V6.56725C4.23342 6.82135 3.77142 7.21405 3.42492 7.74535C3.07842 8.2651 2.90517 8.8426 2.90517 9.47785C2.90517 10.0438 3.04954 10.5866 3.33829 11.1064C3.62704 11.6261 4.00242 12.0189 4.46442 12.2845L7.70419 14.1729ZM11.8102 18.0191C12.4224 18.0191 12.9768 17.8805 13.4734 17.6033C13.9701 17.3261 14.3628 16.9449 14.6515 16.4598C14.9403 15.9747 15.0846 15.4319 15.0846 14.8313V11.0891C15.0846 10.9505 15.0269 10.8465 14.9114 10.7772L13.5947 10.0149V14.8486C13.5947 15.1489 13.4676 15.3741 13.2135 15.5243L9.93912 17.4127C10.5051 17.817 11.1288 18.0191 11.8102 18.0191ZM12.4686 11.6781V9.32192L10.5108 8.21312L8.53579 9.32192V11.6781L10.5108 12.7869L12.4686 11.6781ZM7.40967 6.15145C7.40967 5.85115 7.53672 5.62592 7.79082 5.47577L11.0652 3.58735C10.4993 3.1831 9.87559 2.98097 9.19414 2.98097C8.58199 2.98097 8.02759 3.11957 7.53094 3.39677C7.03429 3.67397 6.64159 4.05512 6.35284 4.54022C6.07564 5.02532 5.93704 5.56817 5.93704 6.16878V9.89365C5.93704 10.0323 5.99479 10.142 6.11029 10.2228L7.40967 10.9851V6.15145ZM16.2108 14.4328C16.7883 14.1787 17.2445 13.786 17.5794 13.2547C17.9259 12.7234 18.0992 12.1459 18.0992 11.5222C18.0992 10.9562 17.9548 10.4134 17.6661 9.89365C17.3773 9.3739 17.0019 8.9812 16.5399 8.71555L13.3002 6.84445C13.2309 6.79825 13.1673 6.78092 13.1096 6.79248C13.0518 6.79248 12.9941 6.8098 12.9363 6.84445L11.637 7.5721L15.8296 10.0149C15.9567 10.0842 16.0491 10.1766 16.1068 10.2921C16.1761 10.3961 16.2108 10.5231 16.2108 10.6733V14.4328ZM12.7284 5.6317C12.9825 5.47 13.2366 5.47 13.4907 5.6317L16.7825 7.55477C16.7825 7.47393 16.7825 7.36997 16.7825 7.24292C16.7825 6.68852 16.6439 6.163 16.3667 5.66635C16.101 5.15815 15.7141 4.7539 15.2059 4.4536C14.7093 4.1533 14.1318 4.00315 13.4734 4.00315C12.8959 4.00315 12.3935 4.12442 11.9661 4.36697L8.70904 6.23808C8.59354 6.31892 8.53579 6.42865 8.53579 6.56725V8.0572L12.7284 5.6317Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* OFFLINE native Pi Google initialization gate (no generation or provider calls).
|
||||||
|
* Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI.
|
||||||
|
* The real onPayload hook throws a unique sentinel BEFORE generateContentStream.
|
||||||
|
* All file modules must resolve inside the payload, including nested dependencies.
|
||||||
|
*
|
||||||
|
* node scripts/smoke-provider-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { createRequire, registerHooks, syncBuiltinESMExports } from "node:module";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs";
|
||||||
|
|
||||||
|
export function verifyProviderRuntime(standalone, runtime = process.execPath) {
|
||||||
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-runtime-"));
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
|
||||||
|
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], {
|
||||||
|
cwd: home,
|
||||||
|
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
|
||||||
|
encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024,
|
||||||
|
});
|
||||||
|
if (result.status !== 0 || result.error) {
|
||||||
|
throw new Error(`provider runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
|
||||||
|
}
|
||||||
|
assert.match(result.stdout, /native Pi Google initialization passed/, "probe exited without success marker");
|
||||||
|
process.stdout.write(result.stdout);
|
||||||
|
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated child only: nothing delegates to a live transport, even if the SDK
|
||||||
|
// changes its fetch path. Count attempts as well: a swallowed guard error fails.
|
||||||
|
function closeNetwork() {
|
||||||
|
let attempts = 0;
|
||||||
|
const deny = () => { attempts++; throw new Error("OFFLINE provider gate: network forbidden"); };
|
||||||
|
globalThis.fetch = deny;
|
||||||
|
globalThis.WebSocket = class { constructor() { deny(); } };
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
for (const [name, methods] of [
|
||||||
|
["http", ["request", "get"]], ["https", ["request", "get"]],
|
||||||
|
["net", ["connect", "createConnection"]], ["tls", ["connect"]],
|
||||||
|
["dgram", ["createSocket"]],
|
||||||
|
["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
|
||||||
|
["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
|
||||||
|
]) {
|
||||||
|
const module = require(`node:${name}`);
|
||||||
|
for (const method of methods) module[method] = deny;
|
||||||
|
}
|
||||||
|
require("node:net").Socket.prototype.connect = deny;
|
||||||
|
syncBuiltinESMExports();
|
||||||
|
return () => assert.equal(attempts, 0, "SDK attempted network access");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(standalone) {
|
||||||
|
const noNetwork = closeNetwork();
|
||||||
|
const root = fs.realpathSync(standalone);
|
||||||
|
const inside = (file) => {
|
||||||
|
const relative = path.relative(root, fs.realpathSync(file));
|
||||||
|
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
|
||||||
|
`dependency escaped standalone payload: ${file}`);
|
||||||
|
};
|
||||||
|
const loaded = new Set();
|
||||||
|
const hooks = registerHooks({
|
||||||
|
resolve(specifier, context, nextResolve) {
|
||||||
|
const result = nextResolve(specifier, context);
|
||||||
|
if (result.url.startsWith("file:")) {
|
||||||
|
inside(fileURLToPath(result.url));
|
||||||
|
loaded.add(fileURLToPath(result.url));
|
||||||
|
} else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Pi is an explicit server external. Import its shipped public entry and
|
||||||
|
// factories, never the checkout's module or a direct/fake Google adapter.
|
||||||
|
const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js"));
|
||||||
|
const { createModels } = await import(entry.href);
|
||||||
|
const models = createModels();
|
||||||
|
for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) {
|
||||||
|
const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory]();
|
||||||
|
models.setProvider(provider);
|
||||||
|
const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai");
|
||||||
|
assert.ok(model, `${name}: missing Google-backed catalog model`);
|
||||||
|
const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`;
|
||||||
|
let reached = 0;
|
||||||
|
const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, {
|
||||||
|
apiKey: "dummy-offline-provider-gate-not-a-key",
|
||||||
|
onPayload(payload, selected) {
|
||||||
|
assert.equal(selected.api, "google-generative-ai");
|
||||||
|
assert.ok(payload && typeof payload === "object");
|
||||||
|
reached++;
|
||||||
|
throw new Error(sentinel);
|
||||||
|
},
|
||||||
|
}).result();
|
||||||
|
assert.equal(reached, 1, `${name}: did not reach onPayload: ${result.errorMessage}`);
|
||||||
|
assert.equal(result.stopReason, "error");
|
||||||
|
assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`);
|
||||||
|
noNetwork();
|
||||||
|
console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`);
|
||||||
|
}
|
||||||
|
// Resolution/evaluation, not folder-exists checks. This also covers the
|
||||||
|
// previously missing transitive edge and permits legitimate nested deps.
|
||||||
|
for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) {
|
||||||
|
assert.ok([...loaded].some((file) => file.includes(`${path.sep}node_modules${path.sep}${name.replaceAll("/", path.sep)}${path.sep}`)),
|
||||||
|
`Google runtime chain was not loaded: ${name}`);
|
||||||
|
}
|
||||||
|
console.log(`[smoke-provider-runtime] native Pi Google initialization passed; ${loaded.size} modules confined to ${root}; zero network attempts`);
|
||||||
|
} finally { hooks.deregister(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args[0] === "--probe") return probe(args[1]);
|
||||||
|
const value = (name, fallback) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index < 0) return fallback;
|
||||||
|
assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
|
||||||
|
return path.resolve(args[index + 1]);
|
||||||
|
};
|
||||||
|
verifyProviderRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
main().catch((error) => { console.error(`[smoke-provider-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; });
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
|
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
|
||||||
|
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as http from "node:http";
|
import * as http from "node:http";
|
||||||
@@ -80,6 +81,8 @@ async function main() {
|
|||||||
// HTTP boot alone never imports the dynamically loaded demo engine.
|
// HTTP boot alone never imports the dynamically loaded demo engine.
|
||||||
// Fail before packaging if its standalone dependency graph is incomplete.
|
// Fail before packaging if its standalone dependency graph is incomplete.
|
||||||
verifyDemoRuntime(STANDALONE, RUNTIME);
|
verifyDemoRuntime(STANDALONE, RUNTIME);
|
||||||
|
// /api/models only reads catalogs; initialize the real lazy SDK offline too.
|
||||||
|
verifyProviderRuntime(STANDALONE, RUNTIME);
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const url = `http://127.0.0.1:${port}`;
|
const url = `http://127.0.0.1:${port}`;
|
||||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { Model } from "@earendil-works/pi-ai";
|
||||||
|
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||||
|
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||||
|
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||||
|
export interface Stamp { epoch: number; authorityRevision: number; revision: number }
|
||||||
|
interface StatusValue extends Stamp { status: ChatGPTStatus }
|
||||||
|
interface ModelsValue extends StatusValue { accountId: string | null; models: { id: string; name: string }[] }
|
||||||
|
interface LeaseValue extends Stamp { accessToken: string; accountId: string }
|
||||||
|
interface Pending { op: string; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
||||||
|
interface BridgeState {
|
||||||
|
status: ChatGPTStatus; stamp: Stamp; models: Model<"openai-responses">[]; catalogAccount: string | null;
|
||||||
|
pending: Map<string, Pending>; controllers: Map<AbortController, boolean>; listeners: Set<() => void>; counter: number; catalogRequest: number;
|
||||||
|
}
|
||||||
|
const key = Symbol.for("lh.chatgpt.bridge");
|
||||||
|
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
|
||||||
|
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
|
||||||
|
function bridge(): BridgeState {
|
||||||
|
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||||
|
if (!global[key]) {
|
||||||
|
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
||||||
|
global[key] = state;
|
||||||
|
process.on("message", (raw: unknown) => {
|
||||||
|
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||||
|
if (m?.channel !== "lh:chatgpt") return;
|
||||||
|
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||||
|
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||||
|
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||||
|
if (m.invalidate === "all" || m.invalidate === "background") {
|
||||||
|
for (const [controller, background] of state.controllers) if (m.invalidate === "all" || background) controller.abort(m.invalidate === "all" ? "all" : "background");
|
||||||
|
}
|
||||||
|
for (const listener of state.listeners) listener();
|
||||||
|
}
|
||||||
|
if (m.id) {
|
||||||
|
const call = state.pending.get(m.id);
|
||||||
|
if (!call || call.op !== m.op) return;
|
||||||
|
clearTimeout(call.timer); state.pending.delete(m.id);
|
||||||
|
if (m.ok === true) call.resolve(m.value);
|
||||||
|
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
process.on("disconnect", () => {
|
||||||
|
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
||||||
|
for (const controller of state.controllers.keys()) controller.abort("all");
|
||||||
|
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
||||||
|
state.pending.clear();
|
||||||
|
for (const listener of state.listeners) listener();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return global[key];
|
||||||
|
}
|
||||||
|
function clearCatalog(state: BridgeState) { state.models = []; state.catalogAccount = null; }
|
||||||
|
function acceptStatus(state: BridgeState, value: StatusValue) {
|
||||||
|
if (!validStamp(value) || value.revision < state.stamp.revision) return;
|
||||||
|
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
||||||
|
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
||||||
|
}
|
||||||
|
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
||||||
|
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
const state = bridge();
|
||||||
|
if (!process.send || !process.connected) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const id = `${process.pid}:${++state.counter}`;
|
||||||
|
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
|
||||||
|
state.pending.set(id, { op, resolve: (v) => resolve(v as T), reject, timer });
|
||||||
|
process.send!({ ...fields, channel: "lh:chatgpt", id, op }, (error) => {
|
||||||
|
if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The ChatGPT desktop connection closed.")); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||||
|
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||||
|
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||||
|
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||||
|
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||||
|
export function chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); }
|
||||||
|
export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
||||||
|
const state = bridge();
|
||||||
|
if (!chatGPTConnected() || !state.catalogAccount || state.catalogAccount !== state.status.activeId || !chatGPTModel(selectedModelId)) throw new Error("ChatGPT account model catalog is unavailable");
|
||||||
|
return { accountId: state.catalogAccount, epoch: state.stamp.epoch, authorityRevision: state.stamp.authorityRevision, selectedModelId };
|
||||||
|
}
|
||||||
|
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||||
|
const s = bridge();
|
||||||
|
return !!process.connected && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||||
|
}
|
||||||
|
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||||
|
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||||
|
const value = await chatGPTRequest<LeaseValue>("lease", { background, expectedAccountId: scope.accountId, expectedAuthorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
|
||||||
|
if (!validStamp(value) || !chatGPTScopeCurrent(scope) || value.epoch !== scope.epoch || value.authorityRevision !== scope.authorityRevision || value.accountId !== scope.accountId || typeof value.accessToken !== "string" || !value.accessToken || value.accessToken.startsWith("sk-")) throw new Error("ChatGPT lease is stale");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
/** Safe status confirmation can establish consent-only denial, never parse error text. */
|
||||||
|
export async function chatGPTDenial(scope: AdmissionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise<NoDispatchKind> {
|
||||||
|
if (callerAborted || controller.signal.reason === "all") return "all";
|
||||||
|
if (!scope || !chatGPTScopeCurrent(scope)) return "other";
|
||||||
|
if (controller.signal.reason === "background") return "background";
|
||||||
|
if (!background) return "other";
|
||||||
|
try { const value = await chatGPTRequest<StatusValue>("status"); acceptStatus(bridge(), value); }
|
||||||
|
catch { return "other"; }
|
||||||
|
if (controller.signal.reason === "all" || !chatGPTScopeCurrent(scope)) return "all";
|
||||||
|
return !chatGPTBackgroundAllowed() ? "background" : "other";
|
||||||
|
}
|
||||||
|
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||||
|
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||||
|
const state = bridge();
|
||||||
|
if (!process.send || !process.connected) { clearCatalog(state); return; }
|
||||||
|
const sequence = ++state.catalogRequest;
|
||||||
|
try {
|
||||||
|
const value = await chatGPTRequest<ModelsValue>("models");
|
||||||
|
if (sequence !== state.catalogRequest || !validStamp(value)) return;
|
||||||
|
acceptStatus(state, value);
|
||||||
|
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.accountId !== state.status.activeId || !chatGPTConnected()) return;
|
||||||
|
state.catalogAccount = value.accountId;
|
||||||
|
state.models = value.models.flatMap((entry) => {
|
||||||
|
const known = supported.find((m) => m.id === entry.id && m.api === "openai-responses");
|
||||||
|
return known ? [{ ...known, provider: "chatgpt", name: entry.name, headers: undefined, samplingParams: undefined, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [];
|
||||||
|
});
|
||||||
|
} catch { if (sequence === state.catalogRequest) clearCatalog(state); }
|
||||||
|
}
|
||||||
|
export function chatGPTAbortController(background = true): { controller: AbortController; release: () => void } {
|
||||||
|
const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, background);
|
||||||
|
return { controller, release: () => { state.controllers.delete(controller); } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
|
||||||
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
|
||||||
|
|
||||||
|
export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
|
||||||
|
export type NoDispatchKind = "background" | "all" | "other";
|
||||||
|
export interface ChatGPTDispatchLifecycle {
|
||||||
|
captured(scope: AdmissionScope): void;
|
||||||
|
beforeDispatch(scope: AdmissionScope): void;
|
||||||
|
noDispatch(kind: NoDispatchKind): void;
|
||||||
|
}
|
||||||
|
interface CycleOptions {
|
||||||
|
initialMessages: AgentMessage[];
|
||||||
|
staged?: AgentMessage[];
|
||||||
|
foregroundOwed: boolean;
|
||||||
|
purpose: { background: boolean };
|
||||||
|
scopeCurrent: (scope: AdmissionScope) => boolean;
|
||||||
|
commitStaged?: () => void;
|
||||||
|
restoreAutomatic: (automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>) => void;
|
||||||
|
}
|
||||||
|
const idOf = (m: AgentMessage) => (m as { id?: string }).id;
|
||||||
|
/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
|
||||||
|
export class ChatGPTRequestCycle implements ChatGPTDispatchLifecycle {
|
||||||
|
private release?: (event: AgentEvent) => void;
|
||||||
|
private held: AgentEvent[] = [];
|
||||||
|
private snapshot: AgentMessage[] = [];
|
||||||
|
private seen: Set<string>;
|
||||||
|
private automatic = new Set<string>();
|
||||||
|
private committed = false;
|
||||||
|
private denied?: NoDispatchKind;
|
||||||
|
private scope?: AdmissionScope;
|
||||||
|
private originalScope?: AdmissionScope;
|
||||||
|
private foregroundOwed: boolean;
|
||||||
|
private inheritedBackground: boolean;
|
||||||
|
private resumed = false;
|
||||||
|
private userAborted = false;
|
||||||
|
private stagedCommitted = false;
|
||||||
|
private started = false;
|
||||||
|
private anyDispatch = false;
|
||||||
|
private firstResolved = false;
|
||||||
|
private resolveAdmission!: (admitted: boolean) => void;
|
||||||
|
readonly admission = new Promise<boolean>((resolve) => { this.resolveAdmission = resolve; });
|
||||||
|
constructor(private readonly options: CycleOptions) {
|
||||||
|
this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
|
||||||
|
for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
|
||||||
|
this.foregroundOwed = options.foregroundOwed;
|
||||||
|
this.inheritedBackground = options.purpose.background;
|
||||||
|
}
|
||||||
|
get dispatched(): boolean { return this.anyDispatch; }
|
||||||
|
get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
|
||||||
|
abort(): void { this.userAborted = true; }
|
||||||
|
bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
|
||||||
|
prepare(request: PrepareRequestContext): void {
|
||||||
|
const messages = request.context.messages as AgentMessage[];
|
||||||
|
this.snapshot = [...messages]; this.automatic = new Set();
|
||||||
|
let learner = false;
|
||||||
|
for (const m of messages) {
|
||||||
|
const id = idOf(m); if (!id || this.seen.has(id)) continue;
|
||||||
|
const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
|
||||||
|
if (cause === "automatic") this.automatic.add(id);
|
||||||
|
if (cause === "learner") learner = true;
|
||||||
|
}
|
||||||
|
if (this.automatic.size > 0) this.options.purpose.background = true;
|
||||||
|
const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
|
||||||
|
this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
|
||||||
|
}
|
||||||
|
hold(event: AgentEvent): boolean {
|
||||||
|
if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
|
||||||
|
if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
|
||||||
|
if (event.type === "turn_start") {
|
||||||
|
this.committed = false; this.denied = undefined; this.scope = undefined;
|
||||||
|
// A retained learner batch from the denied cycle already owns its turn_start.
|
||||||
|
if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!this.committed) { this.held.push(event); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
captured(scope: AdmissionScope): void {
|
||||||
|
if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("ChatGPT recovery scope changed");
|
||||||
|
this.scope = { ...scope };
|
||||||
|
this.originalScope ??= { ...scope };
|
||||||
|
}
|
||||||
|
beforeDispatch(scope: AdmissionScope): void {
|
||||||
|
if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("ChatGPT admission cancelled");
|
||||||
|
if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
|
||||||
|
this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
|
||||||
|
this.stagedCommitted = true;
|
||||||
|
const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
|
||||||
|
const boundary = this.held.findIndex((event) => event.type === "turn_start");
|
||||||
|
this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
|
||||||
|
}
|
||||||
|
this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
|
||||||
|
this.inheritedBackground = this.options.purpose.background;
|
||||||
|
for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
|
||||||
|
const events = this.held; this.held = [];
|
||||||
|
for (const event of events) this.release?.(event);
|
||||||
|
this.resolveFirst(true);
|
||||||
|
}
|
||||||
|
noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
|
||||||
|
acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
|
||||||
|
private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
|
||||||
|
finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
|
||||||
|
if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
|
||||||
|
const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
|
||||||
|
const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
|
||||||
|
this.options.restoreAutomatic(this.automatic, retainedIds);
|
||||||
|
this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
|
||||||
|
this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
|
||||||
|
const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
|
||||||
|
if (retry) {
|
||||||
|
this.resumed = true; this.options.purpose.background = false;
|
||||||
|
this.seen = retainedIds;
|
||||||
|
this.denied = undefined;
|
||||||
|
// Staged automatic input must not be resurrected on foreground recovery.
|
||||||
|
this.options.staged = undefined;
|
||||||
|
return { action: "resumeForeground", messages };
|
||||||
|
}
|
||||||
|
this.held = [];
|
||||||
|
return { action: "settle", messages };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "@earendil-works/pi-ai";
|
||||||
|
import { streamSimple } from "@earendil-works/pi-ai/api/openai-responses";
|
||||||
|
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
||||||
|
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
|
||||||
|
import { chatGPTAbortController, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTDenial } from "./chatgpt-bridge.ts";
|
||||||
|
import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
|
||||||
|
|
||||||
|
const USAGE = "https://chatgpt.com/settings/usage";
|
||||||
|
export function chatGPTStreamError(message: string): string {
|
||||||
|
if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`;
|
||||||
|
if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.";
|
||||||
|
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
|
||||||
|
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
|
||||||
|
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
|
||||||
|
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
|
||||||
|
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
|
||||||
|
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
|
||||||
|
return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
|
||||||
|
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
|
||||||
|
const payload = raw as Record<string, unknown>;
|
||||||
|
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
|
||||||
|
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
|
||||||
|
if (payload[key] !== undefined) out[key] = payload[key];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.input)) throw new Error("ChatGPT requires stateless input history");
|
||||||
|
out.input = payload.input.map((item) => item?.role === "system" ? { ...item, role: "developer" } : item);
|
||||||
|
if (Array.isArray(payload.tools) && payload.tools.length) {
|
||||||
|
if (payload.tools.some((tool) => tool.type !== "function" && tool.type !== "custom")) throw new Error("Unsupported ChatGPT tool capability");
|
||||||
|
out.tools = [{ type: "namespace", name: "learnharness", description: "LearnHarness local learning tools", tools: payload.tools }];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only ChatGPT history is translated. Mixed API-key history keeps its actual identity. */
|
||||||
|
export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<Api>): TranscriptContext {
|
||||||
|
// Apply Pi's normal foreign-model signature treatment BEFORE the alias
|
||||||
|
// becomes openai, or real API-key turns could look like subscription turns.
|
||||||
|
const messages = transformMessages(context.messages, logicalModel);
|
||||||
|
return { ...context, messages: messages.map((m) => m.role === "assistant" && m.provider === "chatgpt" ? { ...m, provider: "openai" } : m) };
|
||||||
|
}
|
||||||
|
function empty(model: Model<Api>): AssistantMessage {
|
||||||
|
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||||
|
}
|
||||||
|
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
|
||||||
|
return (logicalModel, context, options) => {
|
||||||
|
const output = new AssistantMessageEventStream();
|
||||||
|
const purpose = background();
|
||||||
|
const { controller, release } = chatGPTAbortController(purpose);
|
||||||
|
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
||||||
|
void (async () => {
|
||||||
|
let partial = empty(logicalModel);
|
||||||
|
let completed = false;
|
||||||
|
let failure = "";
|
||||||
|
let scope: AdmissionScope | undefined;
|
||||||
|
let committed = false;
|
||||||
|
let terminal = false;
|
||||||
|
const check = () => { if (signal.aborted || !scope || !chatGPTScopeCurrent(scope)) throw new Error("cancelled or catalog authority changed"); };
|
||||||
|
try {
|
||||||
|
if (logicalModel.provider !== "chatgpt") throw new Error("ChatGPT account catalog has no selected model");
|
||||||
|
if (Object.keys(options?.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) throw new Error("Authorization override is forbidden");
|
||||||
|
scope = chatGPTScope(logicalModel.id);
|
||||||
|
lifecycle?.captured(scope);
|
||||||
|
const credentials = await chatGPTLease(scope, purpose);
|
||||||
|
check();
|
||||||
|
// NEVER dispatch through Models under openai: that would expose ambient API-key fallback.
|
||||||
|
const transport: Model<"openai-responses"> = { ...logicalModel, api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", headers: undefined, samplingParams: undefined, compat: { ...logicalModel.compat, supportsDeveloperRole: true } };
|
||||||
|
const delegate = options?.fetch ?? globalThis.fetch;
|
||||||
|
const source = streamSimple(transport, chatGPTHistory(context, logicalModel), {
|
||||||
|
...options,
|
||||||
|
apiKey: credentials.accessToken,
|
||||||
|
signal,
|
||||||
|
maxRetries: 0,
|
||||||
|
headers: { "OpenAI-Beta": null, ...options?.headers },
|
||||||
|
fetch: (input, init) => {
|
||||||
|
check();
|
||||||
|
if (committed) throw new Error("ChatGPT does not retry admitted requests");
|
||||||
|
lifecycle?.beforeDispatch(scope!);
|
||||||
|
committed = true;
|
||||||
|
return delegate(input, init);
|
||||||
|
},
|
||||||
|
onPayload: async (raw, model) => {
|
||||||
|
const replacement = await options?.onPayload?.(raw, model);
|
||||||
|
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
|
||||||
|
check();
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
onProviderStreamEvent: async (raw, model) => {
|
||||||
|
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
|
||||||
|
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
|
||||||
|
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
|
||||||
|
await options?.onProviderStreamEvent?.(raw, model);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for await (const event of source) {
|
||||||
|
const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
|
||||||
|
// Copy the message, not its tool/reasoning blocks: IDs/signatures remain byte-for-byte intact.
|
||||||
|
partial = { ...message, provider: "chatgpt" };
|
||||||
|
if (event.type === "done") {
|
||||||
|
if (!completed || failure) throw new Error(failure || "missing terminal response.completed");
|
||||||
|
terminal = true;
|
||||||
|
output.push({ ...event, message: partial });
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
throw new Error(failure || event.error.errorMessage || "ChatGPT stream error");
|
||||||
|
} else output.push({ ...event, partial });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (terminal) return;
|
||||||
|
if (!committed) lifecycle?.noDispatch(await chatGPTDenial(scope, controller, options?.signal?.aborted === true, purpose));
|
||||||
|
partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: chatGPTStreamError(err instanceof Error ? err.message : "") };
|
||||||
|
output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
|
||||||
|
} finally { release(); output.end(); }
|
||||||
|
})();
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
}
|
||||||
+18
-8
@@ -11,6 +11,17 @@ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { type ModelRole } from "../../config.ts";
|
import { type ModelRole } from "../../config.ts";
|
||||||
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
|
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
|
||||||
|
import packageMetadata from "../../package.json" with { type: "json" };
|
||||||
|
import { chatGPTModel } from "./chatgpt-bridge.ts";
|
||||||
|
import { chatGPTStream } from "./chatgpt-stream.ts";
|
||||||
|
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
|
||||||
|
|
||||||
|
/** App identity is immutable and shared by Models and every native Agent.
|
||||||
|
* Agent's default Pi attribution otherwise overrides the Models identity. */
|
||||||
|
export const APP_IDENTITY = Object.freeze({
|
||||||
|
userAgent: Object.freeze({ custom: `learnharness/${packageMetadata.version}` }),
|
||||||
|
attribution: Object.freeze({ name: packageMetadata.name, url: packageMetadata.homepage }),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one engine factory every agent's model comes from (the pi-ai swap,
|
* The one engine factory every agent's model comes from (the pi-ai swap,
|
||||||
@@ -47,7 +58,7 @@ let collection: ReturnType<typeof createModels> | undefined;
|
|||||||
* keyPresent. */
|
* keyPresent. */
|
||||||
export function piModels() {
|
export function piModels() {
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
collection = createModels();
|
collection = createModels({ identity: APP_IDENTITY });
|
||||||
for (const provider of builtinProviders()) collection.setProvider(provider);
|
for (const provider of builtinProviders()) collection.setProvider(provider);
|
||||||
}
|
}
|
||||||
return collection;
|
return collection;
|
||||||
@@ -85,9 +96,9 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
|
|||||||
* key is resolved by pi-ai itself from the environment per request (the
|
* key is resolved by pi-ai itself from the environment per request (the
|
||||||
* keys seam reflects file-saved keys into env), so the engine never
|
* keys seam reflects file-saved keys into env), so the engine never
|
||||||
* hardcodes OpenRouter's. */
|
* hardcodes OpenRouter's. */
|
||||||
export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
|
||||||
const models: Models = piModels();
|
const models: Models = piModels();
|
||||||
const model = models.getModel(spec.provider, spec.id);
|
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : models.getModel(spec.provider, spec.id);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
||||||
}
|
}
|
||||||
@@ -103,11 +114,10 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
|||||||
// bound streamSimple: the agent loop forwards state.thinkingLevel as
|
// bound streamSimple: the agent loop forwards state.thinkingLevel as
|
||||||
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
|
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
|
||||||
// the pin honest even when config or settings drift off the map.
|
// the pin honest even when config or settings drift off the map.
|
||||||
streamFn: (requestModel, context, options) =>
|
streamFn: (requestModel, context, options) => {
|
||||||
models.streamSimple(requestModel, context, {
|
const stream = spec.provider === "chatgpt" ? chatGPTStream(typeof background === "function" ? background : () => background, lifecycle) : models.streamSimple.bind(models);
|
||||||
...options,
|
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
|
||||||
reasoning: level === "off" ? undefined : level,
|
},
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ The defining condition of practice: the proposition you are exercising is **inac
|
|||||||
Three things this session is not:
|
Three things this session is not:
|
||||||
|
|
||||||
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
|
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
|
||||||
- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing.
|
- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.
|
||||||
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
|
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
|
||||||
|
|
||||||
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
|
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
|
||||||
@@ -73,7 +73,7 @@ Interleaving is the default. Never issue blocks of same-target problems.
|
|||||||
|
|
||||||
1. **Target** — per the selection rules.
|
1. **Target** — per the selection rules.
|
||||||
2. **Mode** — the cheapest undemonstrated layer for that target.
|
2. **Mode** — the cheapest undemonstrated layer for that target.
|
||||||
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets.
|
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.
|
||||||
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
|
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
|
||||||
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
|
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
|
||||||
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
|
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
|
||||||
@@ -101,7 +101,7 @@ Three structural rules:
|
|||||||
|
|
||||||
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
|
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
|
||||||
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
|
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
|
||||||
- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later.
|
- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.
|
||||||
|
|
||||||
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
|
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
|
||||||
|
|
||||||
@@ -109,17 +109,17 @@ The rung a problem needed is a difficulty readout — only R0–R1 means the pro
|
|||||||
|
|
||||||
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
|
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
|
||||||
|
|
||||||
**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on.
|
**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.
|
||||||
|
|
||||||
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
|
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
|
||||||
|
|
||||||
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so.
|
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.
|
||||||
|
|
||||||
## VIII. Progression
|
## VIII. Progression
|
||||||
|
|
||||||
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
|
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
|
||||||
|
|
||||||
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
|
Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.
|
||||||
|
|
||||||
## IX. The arc
|
## IX. The arc
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
graphStore.upsertSource({ id, filePath: input.filePath, importedAt });
|
||||||
|
return await runNotesExtraction({ id, ...input, importedAt });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError) throw err;
|
||||||
|
throw new NotesImportError("failed", { nodes: 0, chains: 0 }, err instanceof Error ? err.message : String(err), { cause: err });
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(id);
|
||||||
}
|
}
|
||||||
const importedAt = new Date().toISOString();
|
|
||||||
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
|
|
||||||
return runNotesExtraction({ id, ...input, importedAt });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { runExtraction, importNotes };
|
return { runExtraction, importNotes };
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export function browserServiceSpawnEnv(
|
|||||||
const out: Record<string, string | undefined> = { ...env };
|
const out: Record<string, string | undefined> = { ...env };
|
||||||
delete out.ELECTRON_RUN_AS_NODE;
|
delete out.ELECTRON_RUN_AS_NODE;
|
||||||
delete out.LH_DEV_URL;
|
delete out.LH_DEV_URL;
|
||||||
|
// The artifact renderer is not an authorized app API client.
|
||||||
|
delete out.LH_DESKTOP_API_SECRET;
|
||||||
|
delete out.LH_DESKTOP_SERVER;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
||||||
import { normalizeContext } from "@earendil-works/pi-ai";
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
@@ -183,6 +184,7 @@ export async function runPdfReaderAgent(
|
|||||||
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
||||||
// factory pin to consult (engine.level is the resolved/clamped value).
|
// factory pin to consult (engine.level is the resolved/clamped value).
|
||||||
const reasoning = engine.level;
|
const reasoning = engine.level;
|
||||||
|
const sessionId = randomUUID();
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`PDF path: ${pdfPath}`,
|
`PDF path: ${pdfPath}`,
|
||||||
`Question (what the teacher needs from this document): ${input.question || "(no question — give a structural overview)"}`,
|
`Question (what the teacher needs from this document): ${input.question || "(no question — give a structural overview)"}`,
|
||||||
@@ -207,6 +209,7 @@ export async function runPdfReaderAgent(
|
|||||||
INSTRUCTIONS,
|
INSTRUCTIONS,
|
||||||
messages,
|
messages,
|
||||||
toolDeclarations,
|
toolDeclarations,
|
||||||
|
sessionId,
|
||||||
reasoning,
|
reasoning,
|
||||||
opts.abortSignal,
|
opts.abortSignal,
|
||||||
opts.chatId ? providerEventObserver(opts.chatId, "job:pdfReader") : undefined,
|
opts.chatId ? providerEventObserver(opts.chatId, "job:pdfReader") : undefined,
|
||||||
@@ -395,6 +398,7 @@ async function streamOnce(
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Tool[],
|
tools: Tool[],
|
||||||
|
sessionId: string,
|
||||||
reasoning: ThinkingLevel | undefined,
|
reasoning: ThinkingLevel | undefined,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
onProviderStreamEvent?: ProviderEventObserver,
|
onProviderStreamEvent?: ProviderEventObserver,
|
||||||
@@ -403,8 +407,8 @@ async function streamOnce(
|
|||||||
// and tool declarations fold into a leading system message.
|
// and tool declarations fold into a leading system message.
|
||||||
// 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 }), {
|
||||||
|
sessionId,
|
||||||
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);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||||
import { piEngineFor, type PiEngine } from "../models.ts";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { APP_IDENTITY, piEngineFor, type PiEngine } from "../models.ts";
|
||||||
import { providerEventObserver } from "../provider-events.ts";
|
import { providerEventObserver } from "../provider-events.ts";
|
||||||
import type { ModelRole } from "../../../config.ts";
|
import type { ModelRole } from "../../../config.ts";
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRu
|
|||||||
// Debug recorder (owner, 2026-10-04): raw provider events per job run.
|
// Debug recorder (owner, 2026-10-04): raw provider events per job run.
|
||||||
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, `job:${opts.role}`) : undefined;
|
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, `job:${opts.role}`) : undefined;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
|
identity: APP_IDENTITY,
|
||||||
|
// One unsaved conversation per invocation, stable through every turn.
|
||||||
|
sessionId: randomUUID(),
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: opts.systemPrompt,
|
systemPrompt: opts.systemPrompt,
|
||||||
model: engine.model,
|
model: engine.model,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Tool outputs may be error strings (e.g. search unconfigured, a page that failed
|
|||||||
Adapt: try another URL or another search angle; if research is genuinely impossible, say so
|
Adapt: try another URL or another search angle; if research is genuinely impossible, say so
|
||||||
in the brief instead of inventing facts.
|
in the brief instead of inventing facts.
|
||||||
|
|
||||||
|
If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources.
|
||||||
|
|
||||||
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
|
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
@@ -70,8 +72,7 @@ export interface ResearcherRunOptions {
|
|||||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||||
* provider per call, null = honestly unavailable (the web_search tool
|
* provider per call, null = honestly unavailable (the web_search tool
|
||||||
* reports that in its output — no crash, no silent fallback). Absent
|
* reports that in its output — no crash, no silent fallback). Absent
|
||||||
* seam reads as unavailable: the server wiring always injects one; the
|
* seam reads as unavailable; web_fetch remains usable without it. */
|
||||||
* dispatch-level drop (jobTools) keeps doomed jobs from even starting. */
|
|
||||||
search?: () => SearchProvider | null;
|
search?: () => SearchProvider | null;
|
||||||
/** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the
|
/** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the
|
||||||
* toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped
|
* toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped
|
||||||
|
|||||||
@@ -82,9 +82,8 @@ export interface JobRunnerDeps {
|
|||||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||||
* provider fresh per job — server/search.ts composition injected by
|
* provider fresh per job — server/search.ts composition injected by
|
||||||
* the wiring; null = honestly unavailable (the researcher's web_search
|
* the wiring; null = honestly unavailable (the researcher's web_search
|
||||||
* reports it). Absent seam (tests) reads the same. The dispatch-level
|
* reports it as recoverable text). Absent seam (tests) reads the same;
|
||||||
* drop (jobTools' searchAvailable) uses the same signal — jobs already
|
* research dispatch and web_fetch do not require search credentials. */
|
||||||
* in flight when a key disappears still fail honestly, never crash. */
|
|
||||||
searchProvider?: () => SearchProvider | null;
|
searchProvider?: () => SearchProvider | null;
|
||||||
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
|
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
|
||||||
* resolved engine's provider + the kind's slot tier, return the
|
* resolved engine's provider + the kind's slot tier, return the
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||||
import { normalizeContext } from "@earendil-works/pi-ai";
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
@@ -142,6 +143,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
|||||||
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
||||||
// factory pin to consult (engine.level is the resolved/clamped value).
|
// factory pin to consult (engine.level is the resolved/clamped value).
|
||||||
const reasoning = engine.level;
|
const reasoning = engine.level;
|
||||||
|
const sessionId = randomUUID();
|
||||||
const prompt = [
|
const prompt = [
|
||||||
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):",
|
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):",
|
||||||
brief,
|
brief,
|
||||||
@@ -164,6 +166,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
|||||||
INSTRUCTIONS,
|
INSTRUCTIONS,
|
||||||
messages,
|
messages,
|
||||||
toolDeclarations,
|
toolDeclarations,
|
||||||
|
sessionId,
|
||||||
reasoning,
|
reasoning,
|
||||||
opts.abortSignal,
|
opts.abortSignal,
|
||||||
opts.chatId ? providerEventObserver(opts.chatId, "job:visualMaker") : undefined,
|
opts.chatId ? providerEventObserver(opts.chatId, "job:visualMaker") : undefined,
|
||||||
@@ -317,6 +320,7 @@ async function streamOnce(
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Tool[],
|
tools: Tool[],
|
||||||
|
sessionId: string,
|
||||||
reasoning: ThinkingLevel | undefined,
|
reasoning: ThinkingLevel | undefined,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
onProviderStreamEvent?: ProviderEventObserver,
|
onProviderStreamEvent?: ProviderEventObserver,
|
||||||
@@ -325,8 +329,8 @@ async function streamOnce(
|
|||||||
// and tool declarations fold into a leading system message.
|
// and tool declarations fold into a leading system message.
|
||||||
// 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 }), {
|
||||||
|
sessionId,
|
||||||
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);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { invalidInputResult, questionResult } from "./tool-execute.ts";
|
|||||||
import { providerEventObserver } from "./provider-events.ts";
|
import { providerEventObserver } from "./provider-events.ts";
|
||||||
import type { Usage } from "@earendil-works/pi-ai";
|
import type { Usage } from "@earendil-works/pi-ai";
|
||||||
import { PATHS } from "../../config.ts";
|
import { PATHS } from "../../config.ts";
|
||||||
import { piEngineFor, type PiEngine } from "./models.ts";
|
import { APP_IDENTITY, piEngineFor, type PiEngine } from "./models.ts";
|
||||||
import { effectivePromptSegments } from "./prompt-segments.ts";
|
import { effectivePromptSegments } from "./prompt-segments.ts";
|
||||||
import {
|
import {
|
||||||
askUserQuestionTool,
|
askUserQuestionTool,
|
||||||
@@ -18,7 +18,8 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
|||||||
import { healToolRecord } from "./tools/heal.ts";
|
import { healToolRecord } from "./tools/heal.ts";
|
||||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||||
import { toLlmMessages } from "../core/native-messages.ts";
|
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
|
||||||
|
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
||||||
@@ -97,6 +98,8 @@ export type StepUsageCollector = (usage: Usage) => void;
|
|||||||
export interface AgentBuildOptions {
|
export interface AgentBuildOptions {
|
||||||
/** Model + stream function — tests inject a scripted faux engine. */
|
/** Model + stream function — tests inject a scripted faux engine. */
|
||||||
engine?: PiEngine;
|
engine?: PiEngine;
|
||||||
|
callPurpose?: { background: boolean };
|
||||||
|
requestCycle?: ChatGPTRequestCycle;
|
||||||
/** The initial transcript, as pi messages (the record IS agent state). */
|
/** The initial transcript, as pi messages (the record IS agent state). */
|
||||||
messages?: AgentMessage[];
|
messages?: AgentMessage[];
|
||||||
/** Money channel: one call per model request. */
|
/** Money channel: one call per model request. */
|
||||||
@@ -116,7 +119,9 @@ export interface AgentBuildOptions {
|
|||||||
* fresh per turn, honoring enabled overrides (switch on + file
|
* fresh per turn, honoring enabled overrides (switch on + file
|
||||||
* exists); absent → the cached pure-source composition. */
|
* exists); absent → the cached pure-source composition. */
|
||||||
promptSwitches?: Record<string, boolean>;
|
promptSwitches?: Record<string, boolean>;
|
||||||
/** Debug: when set AND LH_PROVIDER_EVENTS=1, raw provider stream events
|
/** Native conversation identity: the existing persisted chat id, reused
|
||||||
|
* across reconstructed prober, teacher, and practice Agents.
|
||||||
|
* Debug: when set AND LH_PROVIDER_EVENTS=1, raw provider stream events
|
||||||
* (pre-normalization) append to data/debug/provider-events/
|
* (pre-normalization) append to data/debug/provider-events/
|
||||||
* <chatId>.jsonl via providerEventObserver — the GLM-truncation /
|
* <chatId>.jsonl via providerEventObserver — the GLM-truncation /
|
||||||
* DeepSeek-id incident layer. Absent or env-off → no observer. */
|
* DeepSeek-id incident layer. Absent or env-off → no observer. */
|
||||||
@@ -176,7 +181,10 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
|
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
|
||||||
// the normal no-observer path (tests, env-off).
|
// the normal no-observer path (tests, env-off).
|
||||||
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
|
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
|
||||||
|
const seenInputs = new Set((opts.messages ?? []).map((m) => (m as { id?: string }).id).filter(Boolean));
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
|
identity: APP_IDENTITY,
|
||||||
|
sessionId: opts.chatId,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
model: engine.model,
|
model: engine.model,
|
||||||
@@ -207,13 +215,31 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
// an abort's dangling question call gets its INTERRUPTED result. It
|
// an abort's dangling question call gets its INTERRUPTED result. It
|
||||||
// must never throw (pi's convertToLlm contract).
|
// must never throw (pi's convertToLlm contract).
|
||||||
convertToLlm: toLlmMessages,
|
convertToLlm: toLlmMessages,
|
||||||
|
prepareRequest: (request) => {
|
||||||
|
if (opts.callPurpose) {
|
||||||
|
let learner = false, automatic = false;
|
||||||
|
const messages = request.context.messages;
|
||||||
|
for (const message of messages) {
|
||||||
|
const id = (message as { id?: string }).id;
|
||||||
|
const cause = nativeInputCause(message, messages.slice(0, messages.indexOf(message)));
|
||||||
|
if (cause === "neutral") continue;
|
||||||
|
if (id && seenInputs.has(id)) continue;
|
||||||
|
if (id) seenInputs.add(id);
|
||||||
|
if (cause === "learner" && id) learner = true;
|
||||||
|
else automatic = true; // missing/unrecognized identity never grants foreground
|
||||||
|
}
|
||||||
|
if (automatic) opts.callPurpose.background = true;
|
||||||
|
else if (learner) opts.callPurpose.background = false;
|
||||||
|
}
|
||||||
|
opts.requestCycle?.prepare(request);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (opts.onUsage) {
|
if (opts.onUsage) {
|
||||||
agent.subscribe((event: AgentEvent) => {
|
agent.subscribe((event: AgentEvent) => {
|
||||||
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
|
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
|
||||||
// A cancelled call is not a billed step (partial/estimated
|
// A cancelled call is not a billed step (partial/estimated
|
||||||
// usage on an aborted message is not money spent).
|
// usage on an aborted message is not money spent).
|
||||||
if (event.message.stopReason === "aborted") return;
|
if (event.message.stopReason === "aborted" || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
|
||||||
opts.onUsage!(event.message.usage);
|
opts.onUsage!(event.message.usage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-13
@@ -268,8 +268,7 @@ export interface ProberToolContext extends ProbeDoneContext {
|
|||||||
jobStore: JobStore;
|
jobStore: JobStore;
|
||||||
/** Fire the background runner for a freshly created job. */
|
/** Fire the background runner for a freshly created job. */
|
||||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||||
/** Search availability — the research dispatch tool's honest-disable
|
/** Retained search-status seam; research dispatch is available keylessly. */
|
||||||
* signal (same gate as the teacher's). Absent = available. */
|
|
||||||
searchAvailable?: () => boolean;
|
searchAvailable?: () => boolean;
|
||||||
/** The prober's graph-map reader (wave 7): present = the two map tools
|
/** The prober's graph-map reader (wave 7): present = the two map tools
|
||||||
* (list_sessions / list_propositions) are registered. The composition
|
* (list_sessions / list_propositions) are registered. The composition
|
||||||
@@ -331,13 +330,12 @@ export function createProberTools(
|
|||||||
// research + pdf are ALWAYS available in every phase — the
|
// research + pdf are ALWAYS available in every phase — the
|
||||||
// prober can verify facts or read learner PDFs while assessing.
|
// prober can verify facts or read learner PDFs while assessing.
|
||||||
// demo/visual stay teach-phase-only (they need the lesson's plan
|
// demo/visual stay teach-phase-only (they need the lesson's plan
|
||||||
// context). Same gating as the teacher: toggle-filtered here,
|
// context). Same user-toggle filtering as the teacher; research
|
||||||
// research subject to the searchAvailable gate (honestly absent
|
// remains available without a search-provider key.
|
||||||
// when no engine is live).
|
|
||||||
...(jobs.research ? { research: jobs.research } : {}),
|
...(jobs.research ? { research: jobs.research } : {}),
|
||||||
pdf: jobs.pdf!,
|
pdf: jobs.pdf!,
|
||||||
// The map reader (wave 7): present only when the settings gate
|
// The map reader (wave 7): present only when the settings gate
|
||||||
// opened — same registration-time drop as the search gate.
|
// opened.
|
||||||
...(ctx.graphStore
|
...(ctx.graphStore
|
||||||
? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore })
|
? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore })
|
||||||
: {}),
|
: {}),
|
||||||
@@ -400,9 +398,7 @@ export interface TeacherToolContext extends PlanToolContext {
|
|||||||
* rides along — the spawn fires mid-stream, before the tool part is
|
* rides along — the spawn fires mid-stream, before the tool part is
|
||||||
* persisted to the transcript (a transcript scan would race it). */
|
* persisted to the transcript (a transcript scan would race it). */
|
||||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||||
/** Search availability (search port wave, 2026-09-20): the research
|
/** Retained search-status seam; research dispatch is available keylessly. */
|
||||||
* dispatch tool's honest-disable signal — no live engine = the tool
|
|
||||||
* drops from this registry (JobToolContext.searchAvailable). */
|
|
||||||
searchAvailable?: () => boolean;
|
searchAvailable?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,10 +458,6 @@ export function createTeacherTools(
|
|||||||
find: findTool,
|
find: findTool,
|
||||||
grep: grepTool,
|
grep: grepTool,
|
||||||
...planTools(ctx),
|
...planTools(ctx),
|
||||||
// Fix wave (2026-09-20): searchAvailable was RECEIVED here but
|
|
||||||
// DROPPED when building the inner JobToolContext — the drop-gate
|
|
||||||
// always took its ?? true default. Threaded now: the live signal
|
|
||||||
// reaches jobTools' research gate.
|
|
||||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
|
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
|
||||||
},
|
},
|
||||||
withoutMechanics(enabled),
|
withoutMechanics(enabled),
|
||||||
|
|||||||
+14
-21
@@ -23,11 +23,9 @@ export interface JobToolContext {
|
|||||||
* input rides along because the spawn happens MID-STREAM, before
|
* input rides along because the spawn happens MID-STREAM, before
|
||||||
* onFinish persists the tool part — a transcript scan would race it. */
|
* onFinish persists the tool part — a transcript scan would race it. */
|
||||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||||
/** Search availability (search port wave, 2026-09-20): the honest
|
/** Search-provider availability, retained for existing composition seams.
|
||||||
* disable signal for the research dispatch tool — no live engine
|
* Research dispatch does not depend on it: web_fetch is keyless and
|
||||||
* means NO research tool in the registry (owner decision: registry-
|
* web_search reports a missing provider as a recoverable tool result. */
|
||||||
* level drop, no doomed dispatch, no silent fallback). Absent =
|
|
||||||
* available (tests + any seam without search wiring). */
|
|
||||||
searchAvailable?: () => boolean;
|
searchAvailable?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,25 +125,20 @@ const PDF_DESCRIPTION =
|
|||||||
* context (chatId + jobStore + the spawn seam), so concurrent chats never
|
* context (chatId + jobStore + the spawn seam), so concurrent chats never
|
||||||
* share state. chat-handler wires it each turn; tests wire their own.
|
* share state. chat-handler wires it each turn; tests wire their own.
|
||||||
*
|
*
|
||||||
* The research tool drops from the registry when ctx.searchAvailable reports
|
* Research remains available without a search-provider key; the role's
|
||||||
* no live engine (2026-09-20) — the teacher never sees it, so a keyless
|
* existing user-toggle filtering still applies outside this registry.
|
||||||
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
|
|
||||||
* a key disappears still run to their honest failure (runner backstop).
|
|
||||||
*/
|
*/
|
||||||
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
|
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
|
||||||
const searchOn = ctx.searchAvailable?.() ?? true;
|
|
||||||
return {
|
return {
|
||||||
...(searchOn && {
|
research: {
|
||||||
research: {
|
name: "research",
|
||||||
name: "research",
|
label: "research",
|
||||||
label: "research",
|
description: RESEARCH_DESCRIPTION,
|
||||||
description: RESEARCH_DESCRIPTION,
|
parameters: researchInputSchema,
|
||||||
parameters: researchInputSchema,
|
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
||||||
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||||
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
),
|
||||||
),
|
},
|
||||||
},
|
|
||||||
}),
|
|
||||||
demo: {
|
demo: {
|
||||||
name: "demo",
|
name: "demo",
|
||||||
label: "demo",
|
label: "demo",
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export interface PracticeToolContext {
|
|||||||
graphNodes?: () => ReadonlyArray<Pick<import("../../core/contracts/graph.ts").GraphNode, "id" | "name">>;
|
graphNodes?: () => ReadonlyArray<Pick<import("../../core/contracts/graph.ts").GraphNode, "id" | "name">>;
|
||||||
/** THE RARELY-NEEDED SET (owner round 10, 2026-10-05): the map readers
|
/** THE RARELY-NEEDED SET (owner round 10, 2026-10-05): the map readers
|
||||||
* behind the SAME settings gate as the prober's (graphToolsEnabled at
|
* behind the SAME settings gate as the prober's (graphToolsEnabled at
|
||||||
* the composition root); the research + pdf jobs with the honest
|
* the composition root); the research + pdf jobs (both keyless).
|
||||||
* searchAvailable drop. Each absent (tests) = absent from the registry. */
|
* Each absent context (tests) = absent from the registry. */
|
||||||
graph?: GraphToolContext;
|
graph?: GraphToolContext;
|
||||||
jobs?: JobToolContext;
|
jobs?: JobToolContext;
|
||||||
}
|
}
|
||||||
@@ -202,8 +202,10 @@ export function concludeTool(gate: PracticeGate): AgentTool<typeof concludeInput
|
|||||||
label: "conclude",
|
label: "conclude",
|
||||||
description:
|
description:
|
||||||
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
|
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
|
||||||
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
|
"the learner's composer. After the result, say your closing words about the learner's work if useful, then " +
|
||||||
"up in text). Nothing may conclude unless a problem is active.",
|
"END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's " +
|
||||||
|
"explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, " +
|
||||||
|
"answer their questions about the concluded one. Nothing may conclude unless a problem is active.",
|
||||||
parameters: concludeInputObject,
|
parameters: concludeInputObject,
|
||||||
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
|
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
@@ -223,8 +225,8 @@ export function practiceTools(ctx: PracticeToolContext): Record<string, AgentToo
|
|||||||
const gate = createPracticeGate(ctx);
|
const gate = createPracticeGate(ctx);
|
||||||
// THE RARELY-NEEDED SET (owner round 10): read/ls/find/grep over the
|
// THE RARELY-NEEDED SET (owner round 10): read/ls/find/grep over the
|
||||||
// learner's files (always — the same read-only set the teacher and
|
// learner's files (always — the same read-only set the teacher and
|
||||||
// prober carry), the research + pdf jobs (research honestly absent
|
// prober carry), the research + pdf jobs (available without search
|
||||||
// when no search engine is live; demo/visual stay teach-only), and
|
// keys; demo/visual stay teach-only), and
|
||||||
// the prober's map readers when the settings gate opened. None are
|
// the prober's map readers when the settings gate opened. None are
|
||||||
// toggle-filtered: problem/conclude are the session; these are
|
// toggle-filtered: problem/conclude are the session; these are
|
||||||
// exceptions the doctrine says to reach for rarely.
|
// exceptions the doctrine says to reach for rarely.
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export interface AuthProviderInfo {
|
|||||||
*
|
*
|
||||||
* EXCLUDED by owner spec — providers whose auth is not a plain key this
|
* EXCLUDED by owner spec — providers whose auth is not a plain key this
|
||||||
* wave: github-copilot (OAuth), amazon-bedrock (AWS creds), google-vertex
|
* wave: github-copilot (OAuth), amazon-bedrock (AWS creds), google-vertex
|
||||||
* (ADC), azure-openai-responses (needs a base URL), the cloudflare
|
* (ADC), azure (needs a base URL), the cloudflare
|
||||||
* account-scoped pair. Anthropic keeps ANTHROPIC_API_KEY only — its
|
* account-scoped pair. Anthropic keeps ANTHROPIC_API_KEY only — its
|
||||||
* AUTH_TOKEN/OAUTH_TOKEN alternates stay env-only. openai-codex exposes
|
* AUTH_TOKEN/OAUTH_TOKEN alternates stay env-only. openai-codex exposes
|
||||||
* no env-key (naturally absent). Providers sharing one env var (moonshotai
|
* no env-key (naturally absent). Providers sharing one env var (moonshotai
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Client-safe status only. Credentials and OAuth transaction data never enter this contract. */
|
||||||
|
export interface ChatGPTStatus {
|
||||||
|
available: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
/** App-wide ChatGPT background permission, independent of account selection. */
|
||||||
|
allowBackgroundUse: boolean;
|
||||||
|
activeId: string | null;
|
||||||
|
accounts: { id: string; email: string; registration: string; connected: boolean; planEnabled?: boolean }[];
|
||||||
|
error: string | null;
|
||||||
|
catalogError?: string | null;
|
||||||
|
}
|
||||||
|
export interface ChatGPTControlBridge {
|
||||||
|
status(): Promise<ChatGPTStatus>;
|
||||||
|
login(id?: string): Promise<ChatGPTStatus>;
|
||||||
|
cancel(): Promise<ChatGPTStatus>;
|
||||||
|
select(id: string): Promise<ChatGPTStatus>;
|
||||||
|
/** Explicitly forget every local registration/credential for this verified identity. */
|
||||||
|
disconnect(id: string): Promise<ChatGPTStatus>;
|
||||||
|
logout(id: string): Promise<ChatGPTStatus>;
|
||||||
|
consent(enabled: boolean): Promise<ChatGPTStatus>;
|
||||||
|
onChange(callback: () => void): () => void;
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
||||||
import { callCost, sumSteps } from "./prices.ts";
|
import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
|
||||||
|
|
||||||
/** One pipeline stage's cumulative cost across every run. */
|
/** One pipeline stage's cumulative cost across every run. */
|
||||||
export interface StageCost {
|
export interface StageCost {
|
||||||
|
/** ChatGPT plan stage-runs, excluded from API dollars and unpriced counts. */
|
||||||
|
subscriptionRuns?: number;
|
||||||
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
||||||
dollars: number;
|
dollars: number;
|
||||||
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
||||||
@@ -34,6 +36,7 @@ export interface StageCost {
|
|||||||
|
|
||||||
/** One run in the recent-runs list (extraction or naming). */
|
/** One run in the recent-runs list (extraction or naming). */
|
||||||
export interface RecentRun {
|
export interface RecentRun {
|
||||||
|
subscriptionRuns?: number;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
/** ISO wall-clock. */
|
/** ISO wall-clock. */
|
||||||
at: string;
|
at: string;
|
||||||
@@ -51,9 +54,10 @@ export interface RecentRun {
|
|||||||
|
|
||||||
/** The cost pill's whole payload: stage totals + recent runs. */
|
/** The cost pill's whole payload: stage totals + recent runs. */
|
||||||
export interface GraphCostSummary {
|
export interface GraphCostSummary {
|
||||||
|
subscriptionRuns?: number;
|
||||||
/** Inferred USD across all priced stages of all runs. */
|
/** Inferred USD across all priced stages of all runs. */
|
||||||
totalDollars: number;
|
totalDollars: number;
|
||||||
/** Runs where EVERY stage was unpriced (totalDollars excludes them). */
|
/** Runs with no priced API stage and at least one unknown stage. */
|
||||||
unpricedRuns: number;
|
unpricedRuns: number;
|
||||||
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
|
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
|
||||||
* the embedding stage is gone with the provider embedder; the
|
* the embedding stage is gone with the provider embedder; the
|
||||||
@@ -89,6 +93,7 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
|
|||||||
/** One stage's dollars over its steps; null when the model is unpriced
|
/** One stage's dollars over its steps; null when the model is unpriced
|
||||||
* (all-or-nothing: a stage runs one model). */
|
* (all-or-nothing: a stage runs one model). */
|
||||||
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
||||||
|
if (isChatGPTPlan(model)) return null;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const c = callCost(step, model);
|
const c = callCost(step, model);
|
||||||
@@ -121,13 +126,15 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
|||||||
extraction.runs++;
|
extraction.runs++;
|
||||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
|
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
|
||||||
if (extDollars === null) extraction.unpricedRuns++;
|
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
|
||||||
|
else if (extDollars === null) extraction.unpricedRuns++;
|
||||||
else extraction.dollars += extDollars;
|
else extraction.dollars += extDollars;
|
||||||
|
|
||||||
audit.runs++;
|
audit.runs++;
|
||||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
||||||
if (audDollars === null) audit.unpricedRuns++;
|
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
|
||||||
|
else if (audDollars === null) audit.unpricedRuns++;
|
||||||
else audit.dollars += audDollars;
|
else audit.dollars += audDollars;
|
||||||
|
|
||||||
// Run-level: null only when EVERY stage is unpriced.
|
// Run-level: null only when EVERY stage is unpriced.
|
||||||
@@ -135,8 +142,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
|||||||
const runDollars = allStages.every((d) => d === null)
|
const runDollars = allStages.every((d) => d === null)
|
||||||
? null
|
? null
|
||||||
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
|
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
|
||||||
|
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isChatGPTPlan(stage.model)).length;
|
||||||
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
|
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
|
||||||
runs.push({
|
runs.push({
|
||||||
|
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
||||||
startedAt: run.startedAt,
|
startedAt: run.startedAt,
|
||||||
at: run.at,
|
at: run.at,
|
||||||
durationMs: run.durationMs,
|
durationMs: run.durationMs,
|
||||||
@@ -159,8 +168,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
|||||||
const capped = runs.slice(0, input.recentCap ?? 10);
|
const capped = runs.slice(0, input.recentCap ?? 10);
|
||||||
|
|
||||||
// A run is "unpriced" when it billed nothing countable at all.
|
// A run is "unpriced" when it billed nothing countable at all.
|
||||||
const unpricedRuns = runs.filter((r) => r.dollars === null).length;
|
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
|
||||||
|
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
|
||||||
return {
|
return {
|
||||||
|
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
||||||
totalDollars: extraction.dollars + audit.dollars,
|
totalDollars: extraction.dollars + audit.dollars,
|
||||||
unpricedRuns,
|
unpricedRuns,
|
||||||
stages: { extraction, audit },
|
stages: { extraction, audit },
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export * from "./input-events.ts";
|
|||||||
export * from "./view.ts";
|
export * from "./view.ts";
|
||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
|
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||||
export * from "./cost-summary.ts";
|
export * from "./cost-summary.ts";
|
||||||
export * from "./fuzzy.ts";
|
export * from "./fuzzy.ts";
|
||||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||||
|
|||||||
@@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
|||||||
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
||||||
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
||||||
|
|
||||||
|
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
|
||||||
|
export function repairMathForDisplay(text: string): string {
|
||||||
|
return healOverescapedCommands(restoreMathEscapes(text));
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreMathEscapes(text: string): string {
|
export function restoreMathEscapes(text: string): string {
|
||||||
if (!DEBRIS_RE.test(text)) return text;
|
if (!DEBRIS_RE.test(text)) return text;
|
||||||
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
||||||
|
|||||||
@@ -674,6 +674,57 @@ export function endsInDismissal(messages: readonly LhMessage[]): boolean {
|
|||||||
return last !== undefined && isDismissedResult(last);
|
return last !== undefined && isDismissedResult(last);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RequestCause = "foreground" | "background";
|
||||||
|
export type ReconstructedCause = RequestCause | "ambiguous";
|
||||||
|
export type NativeInputCause = "learner" | "automatic" | "neutral" | "unrecognizable";
|
||||||
|
/** Native causes only: flattened user roles, IDs' prefixes and prose confer no permission. */
|
||||||
|
export function nativeInputCause(message: AgentMessage, history: readonly AgentMessage[]): NativeInputCause {
|
||||||
|
if (message.role === "user") return "learner";
|
||||||
|
if (message.role === "system" || message.role === "assistant") return "neutral";
|
||||||
|
if (message.role === "custom") {
|
||||||
|
if (["probe-handoff", "subagent-steer", "subagent-abort"].includes(message.customType)) return "automatic";
|
||||||
|
if (["practice-thinking", "practice-next"].includes(message.customType)) return "learner";
|
||||||
|
return ["graph-context", "plan-block", "problem-dismissed", "compaction"].includes(message.customType) ? "neutral" : "unrecognizable";
|
||||||
|
}
|
||||||
|
if (message.role === "toolResult") {
|
||||||
|
if (!(QUESTION_TOOLS as readonly string[]).includes(message.toolName) || message.isError || message.details == null || (message.details as { dismissed?: boolean }).dismissed) return "neutral";
|
||||||
|
const details = message.details as Partial<QuestionAnswerData>;
|
||||||
|
if (!["quiz", "ask", "explain"].includes(details.kind ?? "") || typeof details.questionText !== "string" || typeof details.dontKnow !== "boolean" || !Array.isArray(details.answers) || typeof details.answeredAt !== "string") return "unrecognizable";
|
||||||
|
const calls = history.flatMap((m) => m.role === "assistant" ? m.content.filter((b) => b.type === "toolCall" && b.id === message.toolCallId) : []);
|
||||||
|
return calls.length === 1 && calls[0]?.type === "toolCall" && calls[0].name === message.toolName ? "learner" : "unrecognizable";
|
||||||
|
}
|
||||||
|
return "unrecognizable";
|
||||||
|
}
|
||||||
|
/** Full persisted native record, including pre-compaction causes. Never guessed foreground. */
|
||||||
|
export function reconstructRequestCause(messages: readonly LhMessage[]): ReconstructedCause {
|
||||||
|
let inherited: ReconstructedCause = "ambiguous";
|
||||||
|
let triggers: NativeInputCause[] = [];
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
const history: AgentMessage[] = [];
|
||||||
|
const resolve = (): ReconstructedCause => {
|
||||||
|
if (triggers.includes("unrecognizable")) return "ambiguous";
|
||||||
|
const lastLearner = triggers.lastIndexOf("learner"), lastAutomatic = triggers.lastIndexOf("automatic");
|
||||||
|
if (lastLearner < 0 && lastAutomatic < 0) return inherited;
|
||||||
|
if (lastAutomatic < 0) return "foreground";
|
||||||
|
if (lastLearner < lastAutomatic) return "background";
|
||||||
|
return "ambiguous";
|
||||||
|
};
|
||||||
|
for (const message of messages) {
|
||||||
|
const cause = nativeInputCause(message, history);
|
||||||
|
const id = (message as { id?: string }).id;
|
||||||
|
if (id) {
|
||||||
|
const value = JSON.stringify(message);
|
||||||
|
if (seen.has(id)) { if (seen.get(id) !== value) triggers.push("unrecognizable"); continue; }
|
||||||
|
seen.set(id, value);
|
||||||
|
} else if (cause !== "neutral") triggers.push("unrecognizable");
|
||||||
|
if (message.role === "assistant" && !(message.provider === "synthetic" && message.api === "faux" && message.model === "synthetic-conclude" && message.content.some((b) => b.type === "toolCall" && b.name === "conclude" && b.arguments?.outcome === "dismissed"))) {
|
||||||
|
inherited = resolve(); triggers = [];
|
||||||
|
} else if (cause !== "neutral") triggers.push(cause);
|
||||||
|
history.push(message);
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
||||||
|
|||||||
+325
-258
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@
|
|||||||
import { Value } from "typebox/value";
|
import { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||||
import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
|
import { repairMathForDisplay } from "./math-delims.ts";
|
||||||
import { healEncodedArguments } from "./argument-heal.ts";
|
import { healEncodedArguments } from "./argument-heal.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
|||||||
* plus "nabla" as letters), so even runs before a command start inside
|
* plus "nabla" as letters), so even runs before a command start inside
|
||||||
* balanced math spans collapse to one backslash. */
|
* balanced math spans collapse to one backslash. */
|
||||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||||
if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value));
|
if (typeof value === "string") return repairMathForDisplay(value);
|
||||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
|
|||||||
+49
-10
@@ -7,6 +7,10 @@ import {
|
|||||||
type TeachingAgent,
|
type TeachingAgent,
|
||||||
} from "../agent/teacher-agent.ts";
|
} from "../agent/teacher-agent.ts";
|
||||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||||
|
import { chatGPTBackgroundAllowed, chatGPTScopeCurrent } from "../agent/chatgpt-bridge.ts";
|
||||||
|
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||||
|
import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
|
||||||
|
import { reconstructRequestCause } from "../core/native-messages.ts";
|
||||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
import type { ModelRole } from "../../config.ts";
|
import type { ModelRole } from "../../config.ts";
|
||||||
@@ -15,6 +19,7 @@ import {
|
|||||||
noteSteerVisible,
|
noteSteerVisible,
|
||||||
reconcileUserSteers,
|
reconcileUserSteers,
|
||||||
registerLiveAgent,
|
registerLiveAgent,
|
||||||
|
restoreUncommittedAutomatic,
|
||||||
setJobRespawn,
|
setJobRespawn,
|
||||||
setTurnRunner,
|
setTurnRunner,
|
||||||
settleSteers,
|
settleSteers,
|
||||||
@@ -41,7 +46,7 @@ import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
|||||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
||||||
import { catalogContextLimits } from "./model-catalog.ts";
|
import { catalogContextLimits, refreshConnectionCatalog } from "./model-catalog.ts";
|
||||||
import { emit } from "../core/events.ts";
|
import { emit } from "../core/events.ts";
|
||||||
import { settledTurnResponse } from "./turn-runs.ts";
|
import { settledTurnResponse } from "./turn-runs.ts";
|
||||||
import { asAgentMessages } from "../core/native-messages.ts";
|
import { asAgentMessages } from "../core/native-messages.ts";
|
||||||
@@ -201,6 +206,12 @@ export async function handleChatInput(
|
|||||||
// persist (nothing durable changes); wake-pipeline turns ride the same
|
// persist (nothing durable changes); wake-pipeline turns ride the same
|
||||||
// path. The affected tier is strong — the conversation chain's own tier
|
// path. The affected tier is strong — the conversation chain's own tier
|
||||||
// (a chat.model override still resolves through it).
|
// (a chat.model override still resolves through it).
|
||||||
|
if (conversation.provider === "chatgpt") {
|
||||||
|
await refreshConnectionCatalog();
|
||||||
|
if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
|
||||||
|
return Response.json({ error: "ChatGPT background use is off in this app. Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!providerKeyPresent(conversation.provider)) {
|
if (!providerKeyPresent(conversation.provider)) {
|
||||||
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
|
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
|
||||||
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
||||||
@@ -216,12 +227,22 @@ export async function handleChatInput(
|
|||||||
// placement mirrors the BYOK/key gates — BEFORE appends and
|
// placement mirrors the BYOK/key gates — BEFORE appends and
|
||||||
// beginTurn, no liveness effects — and the record re-reads after
|
// beginTurn, no liveness effects — and the record re-reads after
|
||||||
// because compaction appends the marker.
|
// because compaction appends the marker.
|
||||||
const conversationEngine = piEngineForSpec(conversation);
|
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
|
||||||
|
let requestCycle: ChatGPTRequestCycle | undefined;
|
||||||
|
const lifecycle: ChatGPTDispatchLifecycle = {
|
||||||
|
captured: (scope) => requestCycle?.captured(scope),
|
||||||
|
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
|
||||||
|
noDispatch: (kind) => requestCycle?.noDispatch(kind),
|
||||||
|
};
|
||||||
|
let conversationEngine: PiEngine;
|
||||||
|
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
|
||||||
|
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : "The selected model is unavailable." }, { status: 503 }); }
|
||||||
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
|
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
|
||||||
await ensureCompacted(
|
await ensureCompacted(
|
||||||
{
|
{
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
engine: conversationEngine,
|
engine: piEngineForSpec(conversation, true),
|
||||||
|
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||||
modelId: modelIdForCompaction,
|
modelId: modelIdForCompaction,
|
||||||
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
||||||
thresholdTokens: effectiveThresholdTokens(
|
thresholdTokens: effectiveThresholdTokens(
|
||||||
@@ -287,12 +308,14 @@ export async function handleChatInput(
|
|||||||
// ride this same write-ahead - the pump de-queued them at fire, so this
|
// ride this same write-ahead - the pump de-queued them at fire, so this
|
||||||
// is the ONLY copy ever appended.)
|
// is the ONLY copy ever appended.)
|
||||||
const merged = [...record.messages, ...appends];
|
const merged = [...record.messages, ...appends];
|
||||||
|
const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
|
||||||
|
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||||
try {
|
try {
|
||||||
stores.chatStore.saveMessages(chatId, merged);
|
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
||||||
// in the record — flip their jobs now (the wake's steers ride this
|
// in the record — flip their jobs now (the wake's steers ride this
|
||||||
// write-ahead; the delivering turn may run for minutes).
|
// write-ahead; the delivering turn may run for minutes).
|
||||||
for (const appended of appends) {
|
for (const appended of stagedAutomatic ? [] : appends) {
|
||||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -339,8 +362,19 @@ export async function handleChatInput(
|
|||||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||||
|
if (conversation.provider === "chatgpt") requestCycle = new ChatGPTRequestCycle({
|
||||||
|
initialMessages: asAgentMessages(merged),
|
||||||
|
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
||||||
|
foregroundOwed: !callPurpose.background,
|
||||||
|
purpose: callPurpose,
|
||||||
|
scopeCurrent: chatGPTScopeCurrent,
|
||||||
|
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
||||||
|
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||||
|
});
|
||||||
const agentOpts: AgentBuildOptions = {
|
const agentOpts: AgentBuildOptions = {
|
||||||
engine,
|
engine,
|
||||||
|
callPurpose,
|
||||||
|
requestCycle,
|
||||||
messages: asAgentMessages(merged),
|
messages: asAgentMessages(merged),
|
||||||
onUsage: collectUsage,
|
onUsage: collectUsage,
|
||||||
toolToggles: effective.agent.tools,
|
toolToggles: effective.agent.tools,
|
||||||
@@ -439,6 +473,7 @@ export async function handleChatInput(
|
|||||||
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
|
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
|
||||||
const handle = runPiTurn({
|
const handle = runPiTurn({
|
||||||
agent: constructed,
|
agent: constructed,
|
||||||
|
requestCycle,
|
||||||
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
|
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
|
||||||
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
|
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
|
||||||
// default in ui-stream.
|
// default in ui-stream.
|
||||||
@@ -454,13 +489,14 @@ export async function handleChatInput(
|
|||||||
// route through here: they ride the error-result path and reach the
|
// route through here: they ride the error-result path and reach the
|
||||||
// transcript directly (the retry context the model saw).
|
// transcript directly (the retry context the model saw).
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
|
||||||
console.error(`[chat ${chatId}] turn error:`, error);
|
console.error(`[chat ${chatId}] turn error:`, error);
|
||||||
return "An error occurred.";
|
return "An error occurred.";
|
||||||
},
|
},
|
||||||
onFinish: (turnMessages, isAborted) => {
|
onFinish: (turnMessages, isAborted) => {
|
||||||
// Wave B: the record write is the agent's own final state —
|
// Wave B: the record write is the agent's own final state —
|
||||||
// the new messages are the slice past the merged base.
|
// the new messages are the slice past the merged base.
|
||||||
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
|
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
|
||||||
// usage log append (idempotent per turn id) — the stats bar
|
// usage log append (idempotent per turn id) — the stats bar
|
||||||
// refolds this on the client after the turn settles. The
|
// refolds this on the client after the turn settles. The
|
||||||
// provenance message is the turn's FIRST new assistant message.
|
// provenance message is the turn's FIRST new assistant message.
|
||||||
@@ -479,7 +515,7 @@ export async function handleChatInput(
|
|||||||
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
|
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
|
||||||
// no extraction, no graph, no coverage (v1: the session itself
|
// no extraction, no graph, no coverage (v1: the session itself
|
||||||
// is the deliverable).
|
// is the deliverable).
|
||||||
if (record.kind !== "practice" && extractionEnabled(effective.graph)) {
|
if ((!requestCycle || requestCycle.dispatched) && record.kind !== "practice" && extractionEnabled(effective.graph)) {
|
||||||
// Fresh per-turn read of the graph section's chunk budget
|
// Fresh per-turn read of the graph section's chunk budget
|
||||||
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
||||||
// against it and the pipeline's batches share it.
|
// against it and the pipeline's batches share it.
|
||||||
@@ -520,18 +556,20 @@ export async function handleChatInput(
|
|||||||
// beneath-threshold record is a silent no-op. Skipped under
|
// beneath-threshold record is a silent no-op. Skipped under
|
||||||
// tests (the extraction precedent — scripted chats are
|
// tests (the extraction precedent — scripted chats are
|
||||||
// fixtures; the service itself is pinned directly).
|
// fixtures; the service itself is pinned directly).
|
||||||
if (process.env.NODE_ENV !== "test")
|
if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
|
||||||
kickCompaction(() => {
|
kickCompaction(() => {
|
||||||
const fresh = effectiveSettings();
|
const fresh = effectiveSettings();
|
||||||
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
|
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
|
||||||
if (!spec) return null;
|
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
|
||||||
const kickEngine = piEngineForSpec(spec);
|
let kickEngine: PiEngine;
|
||||||
|
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
|
||||||
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
||||||
const freshRecord = stores.chatStore.getChat(chatId);
|
const freshRecord = stores.chatStore.getChat(chatId);
|
||||||
if (!freshRecord) return null;
|
if (!freshRecord) return null;
|
||||||
return {
|
return {
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
engine: kickEngine,
|
engine: kickEngine,
|
||||||
|
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||||
modelId: kickModelId,
|
modelId: kickModelId,
|
||||||
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
||||||
thresholdTokens: effectiveThresholdTokens(
|
thresholdTokens: effectiveThresholdTokens(
|
||||||
@@ -558,6 +596,7 @@ export async function handleChatInput(
|
|||||||
// must cost zero permanently-held slots beyond the one live view. The
|
// must cost zero permanently-held slots beyond the one live view. The
|
||||||
// turn itself is a server-side process (ruling 2026-09-25) — it never
|
// turn itself is a server-side process (ruling 2026-09-25) — it never
|
||||||
// needed this connection, only a view did.
|
// needed this connection, only a view did.
|
||||||
|
if (stagedAutomatic && requestCycle && !(await requestCycle.admission)) return Response.json({ error: "Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The stream never started, so no onFinish will fire — clear the registry
|
// The stream never started, so no onFinish will fire — clear the registry
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
|||||||
/** Everything the service needs from its host (chat-handler wires the
|
/** Everything the service needs from its host (chat-handler wires the
|
||||||
* production versions; tests inject). */
|
* production versions; tests inject). */
|
||||||
export interface CompactionDeps {
|
export interface CompactionDeps {
|
||||||
|
/** Eligibility only for NEW runs; structural waits still apply. */
|
||||||
|
canStart?: () => boolean;
|
||||||
chatStore: ChatStore;
|
chatStore: ChatStore;
|
||||||
/** The chat's conversation engine — also the summarizer's. */
|
/** The chat's conversation engine — also the summarizer's. */
|
||||||
engine: PiEngine;
|
engine: PiEngine;
|
||||||
@@ -267,6 +269,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
|
|||||||
// injected summarizer.
|
// injected summarizer.
|
||||||
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
|
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
|
||||||
await awaitCompaction(chatId);
|
await awaitCompaction(chatId);
|
||||||
|
if (deps.canStart?.() === false) return;
|
||||||
const record = deps.chatStore.getChat(chatId);
|
const record = deps.chatStore.getChat(chatId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
||||||
@@ -291,7 +294,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
|
|||||||
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
|
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
|
||||||
if (state().inFlight.has(chatId)) return;
|
if (state().inFlight.has(chatId)) return;
|
||||||
const deps = buildDeps();
|
const deps = buildDeps();
|
||||||
if (!deps) return;
|
if (!deps || deps.canStart?.() === false) return;
|
||||||
const record = deps.chatStore.getChat(chatId);
|
const record = deps.chatStore.getChat(chatId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/** Per-launch desktop HTTP capability. This is not an OS-user malware
|
||||||
|
* boundary and is not an OAuth credential. Browser-only mode is unchanged. */
|
||||||
|
export function desktopAPIAuthorized(
|
||||||
|
headers: Headers,
|
||||||
|
env: Record<string, string | undefined> = process.env,
|
||||||
|
): boolean {
|
||||||
|
const expected = env.LH_DESKTOP_API_SECRET;
|
||||||
|
if (env.LH_DESKTOP_SERVER !== "1" && !expected) return true;
|
||||||
|
if (!expected || expected.length < 64) return false;
|
||||||
|
const supplied = headers.get("x-learnharness-desktop");
|
||||||
|
if (!supplied) return false;
|
||||||
|
const actual = Buffer.from(supplied);
|
||||||
|
const wanted = Buffer.from(expected);
|
||||||
|
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
+66
-5
@@ -66,7 +66,9 @@ import {
|
|||||||
import type { TurnInput } from "../core/input-events.ts";
|
import type { TurnInput } from "../core/input-events.ts";
|
||||||
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.ts";
|
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.ts";
|
||||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores, effectiveSettings } from "./stores.ts";
|
||||||
|
import { resolveConversationSpec } from "../core/settings-resolution.ts";
|
||||||
|
import { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
import type { Agent } from "@earendil-works/pi-agent-core";
|
import type { Agent } from "@earendil-works/pi-agent-core";
|
||||||
|
|
||||||
@@ -111,6 +113,8 @@ interface WakeState {
|
|||||||
pumping: Set<string>;
|
pumping: Set<string>;
|
||||||
/** Throttle bookkeeping for the stale-job sweep. */
|
/** Throttle bookkeeping for the stale-job sweep. */
|
||||||
lastSweepAt: number;
|
lastSweepAt: number;
|
||||||
|
stopConnectionWatch?: () => void;
|
||||||
|
automaticDeferred: WeakSet<Agent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LhWakeGlobal {
|
interface LhWakeGlobal {
|
||||||
@@ -119,7 +123,7 @@ interface LhWakeGlobal {
|
|||||||
|
|
||||||
function state(): WakeState {
|
function state(): WakeState {
|
||||||
const g = globalThis as typeof globalThis & LhWakeGlobal;
|
const g = globalThis as typeof globalThis & LhWakeGlobal;
|
||||||
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0 });
|
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0, automaticDeferred: new WeakSet() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
|
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
|
||||||
@@ -210,9 +214,18 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
|||||||
* beats the loop's synchronous start-poll drain and the marker rides the
|
* beats the loop's synchronous start-poll drain and the marker rides the
|
||||||
* run's FIRST LLM call (the native equivalent of the old continuation
|
* run's FIRST LLM call (the native equivalent of the old continuation
|
||||||
* coalescing). */
|
* coalescing). */
|
||||||
|
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
|
||||||
|
if (agent && state().automaticDeferred.has(agent)) return false;
|
||||||
|
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
|
||||||
|
return provider !== "chatgpt" || chatGPTBackgroundAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
function handOffEligible(chatId: string, agent: Agent): void {
|
function handOffEligible(chatId: string, agent: Agent): void {
|
||||||
const queue = state().queues.get(chatId);
|
const queue = state().queues.get(chatId);
|
||||||
if (queue && queue.length > 0) {
|
// Keep automatic results in their owning registry. Never mix them into a
|
||||||
|
// foreground batch without express account consent. Learner steers below
|
||||||
|
// remain deliverable, including the first poll of an answer run.
|
||||||
|
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
|
||||||
for (const entry of queue) {
|
for (const entry of queue) {
|
||||||
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
||||||
agent.steer(entry.steer);
|
agent.steer(entry.steer);
|
||||||
@@ -290,9 +303,25 @@ export function reconcileUserSteers(chatId: string, persisted: readonly LhMessag
|
|||||||
/** Clear the live agent at settlement — MUST run before settleSteers so the
|
/** Clear the live agent at settlement — MUST run before settleSteers so the
|
||||||
* reconciliation's re-pump can never hand a marker to the dying agent. */
|
* reconciliation's re-pump can never hand a marker to the dying agent. */
|
||||||
export function unregisterLiveAgent(chatId: string): void {
|
export function unregisterLiveAgent(chatId: string): void {
|
||||||
|
const agent = state().liveAgents.get(chatId);
|
||||||
|
if (agent) state().automaticDeferred.delete(agent);
|
||||||
state().liveAgents.delete(chatId);
|
state().liveAgents.delete(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Restore exact registry objects after native drain but before dispatch.
|
||||||
|
* The vehicle is disposable; result ownership/promises are not. */
|
||||||
|
export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>): void {
|
||||||
|
const s = state();
|
||||||
|
s.automaticDeferred.add(agent);
|
||||||
|
for (const entry of s.queues.get(chatId) ?? []) {
|
||||||
|
if (entry.handedTo === agent && automaticIds.has(entry.steer.id)) entry.handedTo = undefined;
|
||||||
|
}
|
||||||
|
agent.clearSteeringQueue();
|
||||||
|
for (const entry of s.userSteers.get(chatId) ?? []) {
|
||||||
|
if (!retainedIds.has(entry.message.id)) { agent.steer(entry.message); entry.handedTo = agent; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
|
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
|
||||||
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
||||||
*
|
*
|
||||||
@@ -419,6 +448,7 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
const s = state();
|
const s = state();
|
||||||
if (s.pumping.has(chatId)) return;
|
if (s.pumping.has(chatId)) return;
|
||||||
s.pumping.add(chatId);
|
s.pumping.add(chatId);
|
||||||
|
let parked = false;
|
||||||
try {
|
try {
|
||||||
for (let attempt = 0; ; ) {
|
for (let attempt = 0; ; ) {
|
||||||
const queue = s.queues.get(chatId);
|
const queue = s.queues.get(chatId);
|
||||||
@@ -452,6 +482,7 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s.queues.set(chatId, stillPending);
|
s.queues.set(chatId, stillPending);
|
||||||
|
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
|
||||||
|
|
||||||
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
||||||
// streaming turn takes every eligible marker; pi injects at the
|
// streaming turn takes every eligible marker; pi injects at the
|
||||||
@@ -525,7 +556,8 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
// hands them to that turn once the text-claim lands — until then,
|
// hands them to that turn once the text-claim lands — until then,
|
||||||
// settlement re-pumps and lands them right after it). The pause is
|
// settlement re-pumps and lands them right after it). The pause is
|
||||||
// derived from the tail, so any user message un-holds by construction.
|
// derived from the tail, so any user message un-holds by construction.
|
||||||
if (endsInDismissal(fresh.messages)) {
|
if (endsInDismissal(fresh.messages) || !automaticFollowUpAllowed(chatId)) {
|
||||||
|
parked = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +576,15 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
|
if (status === 403) {
|
||||||
|
// Consent may have changed after the last gate. Preserve completed
|
||||||
|
// job/result ownership and wait for an explicit connection change.
|
||||||
|
const back = s.queues.get(chatId) ?? [];
|
||||||
|
back.push(...fire);
|
||||||
|
s.queues.set(chatId, back);
|
||||||
|
parked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status === 409) {
|
if (status === 409) {
|
||||||
const back = s.queues.get(chatId) ?? [];
|
const back = s.queues.get(chatId) ?? [];
|
||||||
back.push(...fire);
|
back.push(...fire);
|
||||||
@@ -573,10 +614,30 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
||||||
// — restarting for them would just park in waitForIdle forever.
|
// — restarting for them would just park in waitForIdle forever.
|
||||||
const q = state().queues.get(chatId) ?? [];
|
const q = state().queues.get(chatId) ?? [];
|
||||||
if (q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection changes are the only wake for consent-deferred automatic work.
|
||||||
|
// No retry timer, inference, or API-key fallback is started while consent is off.
|
||||||
|
state().stopConnectionWatch?.();
|
||||||
|
state().stopConnectionWatch = onChatGPTChange(() => {
|
||||||
|
const s = state();
|
||||||
|
for (const [chatId, agent] of s.liveAgents) {
|
||||||
|
if (agent.state?.model?.provider !== "chatgpt" || automaticFollowUpAllowed(chatId, agent)) continue;
|
||||||
|
const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
|
||||||
|
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
|
||||||
|
if (pending.length === 0) continue;
|
||||||
|
agent.clearSteeringQueue();
|
||||||
|
for (const entry of pending) entry.handedTo = undefined;
|
||||||
|
for (const entry of s.userSteers.get(chatId) ?? []) if (!consumed.has(entry.message.id)) entry.handedTo = undefined;
|
||||||
|
handOffEligible(chatId, agent); // only unconsumed learner input; never replay consumed steers
|
||||||
|
}
|
||||||
|
for (const chatId of s.queues.keys()) {
|
||||||
|
if (automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) void pump(chatId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Flip the job to its final status now that the lesson has resumed. */
|
/** Flip the job to its final status now that the lesson has resumed. */
|
||||||
function finalizeJob(jobId: string): void {
|
function finalizeJob(jobId: string): void {
|
||||||
const job = stores.jobStore.getJob(jobId);
|
const job = stores.jobStore.getJob(jobId);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
validateKeyShape,
|
validateKeyShape,
|
||||||
} from "../core/auth-keys.ts";
|
} from "../core/auth-keys.ts";
|
||||||
import { authStore } from "../store/auth.ts";
|
import { authStore } from "../store/auth.ts";
|
||||||
|
import { chatGPTConnected } from "../agent/chatgpt-bridge.ts";
|
||||||
|
|
||||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
||||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
||||||
@@ -200,6 +201,7 @@ export function keyValue(id: string): string | undefined {
|
|||||||
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||||
* no key at all: nothing to gate, present by definition. */
|
* no key at all: nothing to gate, present by definition. */
|
||||||
export function providerKeyPresent(provider: string): boolean {
|
export function providerKeyPresent(provider: string): boolean {
|
||||||
|
if (provider === "chatgpt") return chatGPTConnected();
|
||||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||||
return info ? keyValue(info.envVar) !== undefined : true;
|
return info ? keyValue(info.envVar) !== undefined : true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { piModels } from "../agent/models.ts";
|
import { piModels } from "../agent/models.ts";
|
||||||
|
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
|
||||||
|
|
||||||
|
export async function refreshConnectionCatalog(): Promise<void> {
|
||||||
|
await refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses"));
|
||||||
|
}
|
||||||
|
function connectionModel(provider: string, id: string): Model<Api> | undefined {
|
||||||
|
return provider === "chatgpt" ? chatGPTModel(id) : piModels().getModel(provider, id);
|
||||||
|
}
|
||||||
|
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt"]; }
|
||||||
import { settingsStore } from "../store/settings.ts";
|
import { settingsStore } from "../store/settings.ts";
|
||||||
import { effectiveSettings } from "./stores.ts";
|
import { effectiveSettings } from "./stores.ts";
|
||||||
import { keyValue } from "./keys.ts";
|
import { keyValue } from "./keys.ts";
|
||||||
@@ -125,7 +134,7 @@ export function catalogProviders(): CatalogProvider[] {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
envVar: p.envVar,
|
envVar: p.envVar,
|
||||||
keyPresent: keyValue(p.envVar) !== undefined,
|
keyPresent: keyValue(p.envVar) !== undefined,
|
||||||
})).sort((a, b) => a.id.localeCompare(b.id));
|
})).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The providers whose key is present — the picker federates exactly
|
/** The providers whose key is present — the picker federates exactly
|
||||||
@@ -171,6 +180,7 @@ export function catalogContextLimits(): Map<string, number> {
|
|||||||
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const model of chatGPTModels()) index.set(`chatgpt:${model.id}`, model.contextWindow);
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +195,8 @@ export function modelCatalog(): ModelCatalogResponse {
|
|||||||
.filter((p) => keyed.has(p.id))
|
.filter((p) => keyed.has(p.id))
|
||||||
.flatMap((p) => piModels().getModels(p.id))
|
.flatMap((p) => piModels().getModels(p.id))
|
||||||
.map(toModelDto)
|
.map(toModelDto)
|
||||||
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
|
||||||
|
.concat(chatGPTModels().map(toModelDto));
|
||||||
return {
|
return {
|
||||||
models,
|
models,
|
||||||
defaults: catalogDefaults(),
|
defaults: catalogDefaults(),
|
||||||
@@ -207,13 +218,13 @@ export type SettingsValidation =
|
|||||||
* the model the picker writes against (settings.model, else config's
|
* the model the picker writes against (settings.model, else config's
|
||||||
* prober/teacher pin; one controller, so the two specs share it). */
|
* prober/teacher pin; one controller, so the two specs share it). */
|
||||||
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
|
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
|
||||||
const providers = piModels().getProviders().map((p) => p.id);
|
const providers = connectionProviderIds();
|
||||||
if (settings.model) {
|
if (settings.model) {
|
||||||
const { provider, id } = settings.model;
|
const { provider, id } = settings.model;
|
||||||
if (!providers.includes(provider)) {
|
if (!providers.includes(provider)) {
|
||||||
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
|
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
|
||||||
}
|
}
|
||||||
if (!piModels().getModel(provider, id)) {
|
if (!connectionModel(provider, id)) {
|
||||||
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
|
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,13 +248,13 @@ export function validateModelsSettings(settings: ModelsSettings): SettingsValida
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }): SettingsValidation {
|
function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }): SettingsValidation {
|
||||||
const providers = piModels().getProviders().map((p) => p.id);
|
const providers = connectionProviderIds();
|
||||||
if (slot.model) {
|
if (slot.model) {
|
||||||
const { provider, id } = slot.model;
|
const { provider, id } = slot.model;
|
||||||
if (!providers.includes(provider)) {
|
if (!providers.includes(provider)) {
|
||||||
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
|
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
|
||||||
}
|
}
|
||||||
if (!piModels().getModel(provider, id)) {
|
if (!connectionModel(provider, id)) {
|
||||||
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
|
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +276,7 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
|
|||||||
* or the level already supported — the existence gate rejects unknown
|
* or the level already supported — the existence gate rejects unknown
|
||||||
* models separately, so this only ever sees real ones in practice). */
|
* models separately, so this only ever sees real ones in practice). */
|
||||||
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
||||||
const model = piModels().getModel(provider, id);
|
const model = connectionModel(provider, id);
|
||||||
return model ? clampThinkingLevel(model, level) : level;
|
return model ? clampThinkingLevel(model, level) : level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-c
|
|||||||
import type { SteerMessage } from "../../core/contracts.ts";
|
import type { SteerMessage } from "../../core/contracts.ts";
|
||||||
import { noteSteerVisible } from "../jobs.ts";
|
import { noteSteerVisible } from "../jobs.ts";
|
||||||
import { ToolCallIdUniquifier } from "./call-ids.ts";
|
import { ToolCallIdUniquifier } from "./call-ids.ts";
|
||||||
|
import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
|
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
|
||||||
@@ -71,6 +72,7 @@ export type TurnWireEvent =
|
|||||||
| { type: "error"; errorText: string };
|
| { type: "error"; errorText: string };
|
||||||
|
|
||||||
export interface PiTurnSpec {
|
export interface PiTurnSpec {
|
||||||
|
requestCycle?: ChatGPTRequestCycle;
|
||||||
/** The per-turn agent (stateless engine — built from the record). */
|
/** The per-turn agent (stateless engine — built from the record). */
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
/** The handler's error-masking policy for LOOP-level failures (the
|
/** The handler's error-masking policy for LOOP-level failures (the
|
||||||
@@ -117,7 +119,7 @@ export interface PiTurnHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||||
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100 } = spec;
|
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||||
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -248,11 +250,24 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
settleWith();
|
settleWith();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
agent.subscribe(handle);
|
requestCycle?.bindRelease(handle);
|
||||||
|
agent.subscribe((event) => {
|
||||||
|
if (!requestCycle?.hold(event)) handle(event);
|
||||||
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await agent.continue();
|
do {
|
||||||
|
await agent.continue();
|
||||||
|
if (!requestCycle) break;
|
||||||
|
const denied = requestCycle.deniedBeforeDispatch;
|
||||||
|
const outcome = requestCycle.finishNativeCycle();
|
||||||
|
if (denied) agent.state.messages = outcome.messages;
|
||||||
|
if (outcome.action !== "resumeForeground") {
|
||||||
|
handle({ type: "agent_end", messages: [...agent.state.messages] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while (!sawAbort);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A loop-level failure (not a provider error — those ride the
|
// A loop-level failure (not a provider error — those ride the
|
||||||
// stream as stopReason "error"): surface, close, persist. If the
|
// stream as stopReason "error"): surface, close, persist. If the
|
||||||
@@ -318,6 +333,7 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
abort() {
|
abort() {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
sawAbort = true;
|
sawAbort = true;
|
||||||
|
requestCycle?.abort();
|
||||||
agent.abort();
|
agent.abort();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* ALL the env vars a provider reads as API keys.
|
* ALL the env vars a provider reads as API keys.
|
||||||
*
|
*
|
||||||
* Owner-spec exclusions (asserted ABSENT): github-copilot (OAuth),
|
* Owner-spec exclusions (asserted ABSENT): github-copilot (OAuth),
|
||||||
* amazon-bedrock (AWS creds), google-vertex (ADC), azure-openai-responses
|
* amazon-bedrock (AWS creds), google-vertex (ADC), azure
|
||||||
* (needs base URL), the cloudflare account-scoped pair; anthropic keeps
|
* (needs base URL), the cloudflare account-scoped pair; anthropic keeps
|
||||||
* ANTHROPIC_API_KEY only (AUTH_TOKEN/OAUTH_TOKEN stay env-only).
|
* ANTHROPIC_API_KEY only (AUTH_TOKEN/OAUTH_TOKEN stay env-only).
|
||||||
*/
|
*/
|
||||||
@@ -32,7 +32,7 @@ const EXCLUDED_PROVIDERS = new Set([
|
|||||||
"github-copilot",
|
"github-copilot",
|
||||||
"amazon-bedrock",
|
"amazon-bedrock",
|
||||||
"google-vertex",
|
"google-vertex",
|
||||||
"azure-openai-responses",
|
"azure",
|
||||||
"cloudflare-workers-ai",
|
"cloudflare-workers-ai",
|
||||||
"cloudflare-ai-gateway",
|
"cloudflare-ai-gateway",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||||
|
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||||
|
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
||||||
|
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
|
||||||
|
const initial = () => ({ version: 1, hostId: "urn:uuid:00000000-0000-4000-8000-000000000001", activeId: null as string | null, registrations: [] as any[] });
|
||||||
|
async function signed(client: string, nonce: string, subject = "scratch-user", overrides: Record<string, unknown> = {}) {
|
||||||
|
return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey);
|
||||||
|
}
|
||||||
|
function deferred<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||||
|
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
||||||
|
let saved = structuredClone(options.state ?? initial());
|
||||||
|
let url!: URL;
|
||||||
|
let writeCount = 0;
|
||||||
|
const changed = deferred();
|
||||||
|
const requests: { url: string; form?: URLSearchParams }[] = [];
|
||||||
|
const service = createChatGPT({
|
||||||
|
store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
|
||||||
|
openExternal: async (value: string) => { url = new URL(value); },
|
||||||
|
timeoutMs: options.timeoutMs ?? 5000,
|
||||||
|
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
|
||||||
|
fetch: async (target: string, init: RequestInit = {}) => {
|
||||||
|
const form = init.body instanceof URLSearchParams ? init.body : undefined;
|
||||||
|
requests.push({ url: target, form });
|
||||||
|
const response = await options.provider?.(target, init); if (response) return response;
|
||||||
|
if (target.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
|
||||||
|
if (target === tokenUrl) {
|
||||||
|
if (options.token) return options.token(form!, url);
|
||||||
|
return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form!.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: options.expires ?? 3600, scope: SCOPES.join(" ") });
|
||||||
|
}
|
||||||
|
if (target.endsWith("openid-configuration")) return Response.json({ issuer: "https://auth.openai.com", revocation_endpoint: "https://auth.openai.com/api/accounts/oauth/revoke" });
|
||||||
|
if (target.endsWith("/revoke")) return new Response(null, { status: 200 });
|
||||||
|
if (target.endsWith("/models")) return Response.json({ models: [{ slug: "gpt-5", display_name: "Scratch model", visibility: "list" }, { slug: "hidden", display_name: "Hidden", visibility: "hide" }] });
|
||||||
|
throw new Error("Unexpected network request in scratch fixture");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rawLease = service.lease;
|
||||||
|
let discovered: Promise<any> | undefined;
|
||||||
|
service.lease = async (background: boolean) => {
|
||||||
|
discovered ??= service.models();
|
||||||
|
const catalog = await discovered;
|
||||||
|
return rawLease(background, { expectedAccountId: catalog.accountId ?? saved.activeId ?? "scratch-missing", expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" });
|
||||||
|
};
|
||||||
|
return { service, rawLease, requests, changed, get url() { return url; }, get saved() { return saved; }, get writeCount() { return writeCount; }, async callback(params: Record<string, string> = {}) {
|
||||||
|
const callback = new URL(url.searchParams.get("redirect_uri")!);
|
||||||
|
callback.search = new URLSearchParams({ state: url.searchParams.get("state")!, code: "scratch-code", client_id: "oaiapp_scratch", ...params }).toString();
|
||||||
|
return fetch(callback);
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
async function loggedIn(options: Parameters<typeof fixture>[0] = {}) {
|
||||||
|
const f = fixture(options);
|
||||||
|
await f.service.login();
|
||||||
|
await f.callback();
|
||||||
|
await f.changed.promise;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("PKCE/state/nonce/host identity and exact ephemeral 127.0.0.1 callback; no secret renderer snapshot", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
await f.service.login();
|
||||||
|
const params = f.url.searchParams;
|
||||||
|
assert.equal(params.get("client_id"), "dynamic_agent_client");
|
||||||
|
assert.equal(params.get("agent_name_hint"), "LearnHarness");
|
||||||
|
assert.equal(params.get("ext_agent_host_id"), f.saved.hostId);
|
||||||
|
assert.equal(new URL(params.get("redirect_uri")!).hostname, "127.0.0.1");
|
||||||
|
assert.equal(new URL(params.get("redirect_uri")!).pathname, "/auth/callback");
|
||||||
|
assert.equal((await f.callback({ state: "wrong" })).status, 400);
|
||||||
|
assert.equal(f.requests.length, 0, "unverified callback must never exchange a code");
|
||||||
|
await f.callback(); await f.changed.promise;
|
||||||
|
const form = f.requests.find((r) => r.url === tokenUrl)!.form!;
|
||||||
|
assert.equal(form.get("redirect_uri"), params.get("redirect_uri"));
|
||||||
|
assert.equal(form.get("client_id"), "oaiapp_scratch");
|
||||||
|
assert.equal(createHash("sha256").update(form.get("code_verifier")!).digest("base64url"), params.get("code_challenge"));
|
||||||
|
assert.equal(form.get("resource"), "https://api.openai.com/v1");
|
||||||
|
const status = await f.service.status();
|
||||||
|
assert.equal(status.allowBackgroundUse, true);
|
||||||
|
assert.equal(f.saved.registrations[0].background, undefined, "new registrations do not store obsolete per-account preferences");
|
||||||
|
assert.equal(status.activeId, f.saved.activeId);
|
||||||
|
assert(!JSON.stringify(status).includes("scratch-oauth-token"));
|
||||||
|
assert(!JSON.stringify(status).includes("scratch-refresh"));
|
||||||
|
assert(!JSON.stringify(status).includes("idToken"));
|
||||||
|
assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
|
||||||
|
assert.equal((await f.service.lease(true)).accessToken, "scratch-oauth-token");
|
||||||
|
await f.service.consent(false);
|
||||||
|
await assert.rejects(f.service.lease(true), /background/);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returning sign-in reuses issued client/host and fresh PKCE; rejects account/client mismatch without replacing active credentials", async () => {
|
||||||
|
const f = await loggedIn();
|
||||||
|
try {
|
||||||
|
const first = f.url;
|
||||||
|
const status = await f.service.status();
|
||||||
|
await f.service.login(status.activeId);
|
||||||
|
assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch");
|
||||||
|
assert.equal(f.url.searchParams.get("agent_name_hint"), null);
|
||||||
|
assert(f.url.searchParams.get("id_token_hint"));
|
||||||
|
assert.notEqual(f.url.searchParams.get("state"), first.searchParams.get("state"));
|
||||||
|
assert.notEqual(f.url.searchParams.get("nonce"), first.searchParams.get("nonce"));
|
||||||
|
assert.equal(f.url.searchParams.get("ext_agent_host_id"), first.searchParams.get("ext_agent_host_id"));
|
||||||
|
await f.callback({ client_id: "oaiapp_other" });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(f.saved.registrations.length, 1);
|
||||||
|
assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, claims] of [["nonce", { nonce: "wrong" }], ["issuer", { iss: "https://evil.invalid" }], ["audience", { aud: "oaiapp_wrong" }], ["expiry", { exp: 1 }]] as const) {
|
||||||
|
test(`rejects invalid ID-token ${label}`, async () => {
|
||||||
|
const f = fixture({ token: async (form, url) => {
|
||||||
|
const jwt = new SignJWT(Object.assign({ nonce: url.searchParams.get("nonce")!, email: "dummy@example.invalid", iss: "https://auth.openai.com", aud: form.get("client_id")!, sub: "scratch-user", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }, claims));
|
||||||
|
return Response.json({ access_token: "scratch-access", refresh_token: "scratch-refresh", id_token: await jwt.setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).sign(privateKey), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
|
||||||
|
} });
|
||||||
|
try { await f.service.login(); await f.callback(); await f.changed.promise; assert.equal(f.saved.activeId, null); assert.equal(f.saved.registrations.length, 0); }
|
||||||
|
finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cancel and timeout close the listener without exchanging or replacing an active account", async () => {
|
||||||
|
const f = fixture({ timeoutMs: 30 });
|
||||||
|
try {
|
||||||
|
await f.service.login(); const callback = f.url.searchParams.get("redirect_uri")!;
|
||||||
|
f.service.cancelLogin();
|
||||||
|
await assert.rejects(fetch(callback));
|
||||||
|
assert.equal(f.saved.activeId, null);
|
||||||
|
const timeout = fixture({ timeoutMs: 15 });
|
||||||
|
try {
|
||||||
|
await timeout.service.login();
|
||||||
|
await timeout.changed.promise;
|
||||||
|
assert.equal((await timeout.service.status()).pending, false);
|
||||||
|
assert.equal(timeout.requests.length, 0);
|
||||||
|
} finally { timeout.service.close(); }
|
||||||
|
assert.equal(f.requests.length, 0);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing plan scope retains verified registration but refuses inference and catalog", async () => {
|
||||||
|
const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), scope: "openid profile email", token_type: "Bearer" }) });
|
||||||
|
try {
|
||||||
|
assert.equal(f.saved.registrations.length, 1);
|
||||||
|
assert.equal((await f.service.status()).accounts[0].planEnabled, false);
|
||||||
|
await assert.rejects(f.service.lease(false), /scope/);
|
||||||
|
assert.deepEqual((await f.service.models()).models, []);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("durable encrypted save failure never activates a pending login", async () => {
|
||||||
|
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
|
||||||
|
try {
|
||||||
|
await f.service.login(); await f.callback(); await f.changed.promise;
|
||||||
|
assert.equal(f.saved.activeId, null);
|
||||||
|
await assert.rejects(f.service.lease(false), /disconnected/);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serialized concurrent refresh rotates once and persists before leases", async () => {
|
||||||
|
let refreshes = 0;
|
||||||
|
const f = await loggedIn({ expires: 1, token: async (form, url) => {
|
||||||
|
if (form.get("grant_type") === "refresh_token") { refreshes++; assert.equal(form.get("scope"), null); return Response.json({ access_token: "scratch-new-access", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); }
|
||||||
|
return Response.json({ access_token: "scratch-old-access", refresh_token: "scratch-old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
const leases = await Promise.all([f.service.lease(false), f.service.lease(false), f.service.lease(false)]);
|
||||||
|
assert.equal(refreshes, 1);
|
||||||
|
assert(leases.every((lease) => lease.accessToken === "scratch-new-access"));
|
||||||
|
assert.equal(f.saved.registrations[0].tokens.refreshToken, "scratch-rotated");
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout wins an in-flight refresh and retains only account/client/host mapping", async () => {
|
||||||
|
const started = deferred(); const release = deferred();
|
||||||
|
const f = await loggedIn({ token: async (form, url) => {
|
||||||
|
if (form.get("grant_type") === "refresh_token") { started.resolve(); await release.promise; return Response.json({ access_token: "scratch-late", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); }
|
||||||
|
return Response.json({ access_token: "scratch-old", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
const id = f.saved.activeId;
|
||||||
|
const refresh = f.service.lease(false); const rejected = assert.rejects(refresh);
|
||||||
|
await started.promise;
|
||||||
|
const logout = f.service.logout(id);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve)); release.resolve();
|
||||||
|
await rejected; await logout;
|
||||||
|
assert.equal(f.saved.activeId, null);
|
||||||
|
assert.equal(f.saved.registrations[0].clientId, "oaiapp_scratch");
|
||||||
|
assert.equal(f.saved.registrations[0].tokens, undefined);
|
||||||
|
assert(f.requests.some((r) => r.form?.get("token_type_hint") === "refresh_token"));
|
||||||
|
await f.service.login(id);
|
||||||
|
assert.equal(f.url.searchParams.get("id_token_hint"), null);
|
||||||
|
assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch");
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("account catalog is scoped, visibility filtered, and cleared on disconnect", async () => {
|
||||||
|
const f = await loggedIn();
|
||||||
|
try {
|
||||||
|
assert.deepEqual((await f.service.models()).models, [{ id: "gpt-5", name: "Scratch model" }]);
|
||||||
|
await f.service.logout(f.saved.activeId);
|
||||||
|
assert.deepEqual((await f.service.models()).models, []);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
|
||||||
|
const file = path.join(dir, "credentials.enc.json");
|
||||||
|
const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") };
|
||||||
|
try {
|
||||||
|
const store = encryptedStore(file, secure);
|
||||||
|
const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
|
||||||
|
await store.write(data);
|
||||||
|
assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
|
||||||
|
assert.deepEqual(await store.read(), data);
|
||||||
|
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
||||||
|
assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
|
||||||
|
for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
|
||||||
|
await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
|
||||||
|
await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
|
||||||
|
}
|
||||||
|
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
|
||||||
|
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
|
||||||
|
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => {
|
||||||
|
const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
|
||||||
|
? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
|
||||||
|
: Response.json({ access_token: "old-verified-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }) });
|
||||||
|
try {
|
||||||
|
const before = structuredClone(f.saved.registrations[0]);
|
||||||
|
await assert.rejects(f.service.lease(false));
|
||||||
|
const restricted = f.saved.registrations[0];
|
||||||
|
assert.equal(restricted.reconnectRequired, true);
|
||||||
|
for (const field of ["id", "issuer", "subject", "email", "clientId"]) assert.equal(restricted[field], before[field]);
|
||||||
|
assert.deepEqual(restricted.tokens, { ...before.tokens, refreshToken: "likely-latest-revoke-handle" });
|
||||||
|
assert(!JSON.stringify(f.saved).includes("never-save-this-access"));
|
||||||
|
const restarted = fixture({ state: f.saved });
|
||||||
|
try {
|
||||||
|
await restarted.service.status();
|
||||||
|
await restarted.service.select(restricted.id);
|
||||||
|
restarted.service.resume(); await restarted.service.consent(true);
|
||||||
|
await assert.rejects(restarted.service.lease(false));
|
||||||
|
assert.equal((await restarted.service.status()).accounts[0].connected, false);
|
||||||
|
assert.deepEqual((await restarted.service.models()).models, []);
|
||||||
|
assert.equal(restarted.requests.filter((r) => r.url === tokenUrl).length, 0, "restricted material cannot refresh for inference/catalog");
|
||||||
|
await restarted.service.logout(restricted.id);
|
||||||
|
assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
|
||||||
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
||||||
|
await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
|
||||||
|
// Wait for callback's asynchronous encrypted activation, not the prior status push.
|
||||||
|
for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
|
||||||
|
} finally { restarted.service.close(); }
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => {
|
||||||
|
let lost = false; const pushes: any[] = [];
|
||||||
|
const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) });
|
||||||
|
try {
|
||||||
|
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
||||||
|
lost = true;
|
||||||
|
assert.equal((await f.service.status()).available, false);
|
||||||
|
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
|
||||||
|
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
|
||||||
|
lost = false; f.service.resume(); await f.service.consent(true);
|
||||||
|
await assert.rejects(f.service.lease(false));
|
||||||
|
assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
|
||||||
|
const f = await loggedIn(); f.service.close();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
||||||
|
const file = path.join(dir, "credentials.enc.json");
|
||||||
|
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
|
||||||
|
try {
|
||||||
|
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
|
||||||
|
const state = structuredClone(f.saved); mutate(state);
|
||||||
|
const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
|
||||||
|
await fs.writeFile(file, bytes);
|
||||||
|
const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
|
||||||
|
try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
|
||||||
|
finally { service.close(); }
|
||||||
|
}
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consent OFF wins an older ON save; foreground catalog authority remains unchanged", async () => {
|
||||||
|
let hold = false; const entered = deferred(); const release = deferred();
|
||||||
|
const f = await loggedIn({ write: async (next) => { if (hold && next.allowBackgroundUse === true) { entered.resolve(); await release.promise; } } });
|
||||||
|
try {
|
||||||
|
const catalog = await f.service.models(); const id = f.saved.activeId;
|
||||||
|
hold = true;
|
||||||
|
const on = f.service.consent(true); await entered.promise;
|
||||||
|
const off = f.service.consent(false);
|
||||||
|
assert.equal((await f.service.status()).allowBackgroundUse, false);
|
||||||
|
release.resolve(); await on; await off;
|
||||||
|
assert.equal(f.saved.allowBackgroundUse, false);
|
||||||
|
assert.equal(f.service.stamp().authorityRevision, catalog.authorityRevision);
|
||||||
|
assert.equal((await f.rawLease(false, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, id);
|
||||||
|
await assert.rejects(f.rawLease(true, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }), /background/);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancellation during verified durable save cannot activate late credentials", async () => {
|
||||||
|
const entered = deferred(); const release = deferred(); let blocked = true;
|
||||||
|
const f = fixture({ write: async (next) => { if (blocked && next.registrations.length) { entered.resolve(); await release.promise; blocked = false; } } });
|
||||||
|
try {
|
||||||
|
await f.service.login(); await f.callback(); await entered.promise;
|
||||||
|
f.service.cancelLogin(); release.resolve();
|
||||||
|
for (let i = 0; i < 30 && f.writeCount < 2; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(f.saved.activeId, null);
|
||||||
|
assert.equal((await f.service.status()).accounts.length, 0);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scope-disabled credentials remain revocable and unconfirmed revoke never claims success", async () => {
|
||||||
|
const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), refresh_token: "scope-disabled-refresh", scope: "openid profile email", token_type: "Bearer" }), provider: async (url) => url.endsWith("/revoke") ? new Response(null, { status: 500 }) : undefined });
|
||||||
|
try {
|
||||||
|
await assert.rejects(f.service.lease(false));
|
||||||
|
const status = await f.service.logout(f.saved.activeId);
|
||||||
|
assert.equal(status.error, "Account disconnected locally. Remote revocation could not be confirmed.");
|
||||||
|
assert.equal(f.requests.filter((r) => r.form?.get("token") === "scope-disabled-refresh").length, 2);
|
||||||
|
assert.equal(f.saved.registrations[0].tokens, undefined);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting", async () => {
|
||||||
|
const f = await loggedIn();
|
||||||
|
try {
|
||||||
|
const original = f.saved.registrations[0];
|
||||||
|
await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" });
|
||||||
|
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||||
|
const status = await f.service.status();
|
||||||
|
assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1);
|
||||||
|
assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id);
|
||||||
|
assert.equal(f.saved.registrations[0].clientId, original.clientId); assert.equal(f.saved.registrations[1].clientId, "oaiapp_repeat");
|
||||||
|
await f.service.disconnect(status.activeId);
|
||||||
|
assert.deepEqual(f.saved.registrations, []); assert.equal((await f.service.status()).accounts.length, 0);
|
||||||
|
assert.equal(f.requests.filter((r) => r.form?.has("token_type_hint")).length, 2);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verified identity grouping hides legacy duplicate registrations without merging client credentials or matching emails", async () => {
|
||||||
|
const first = await loggedIn(); first.service.close();
|
||||||
|
const state = structuredClone(first.saved);
|
||||||
|
const original = state.registrations[0];
|
||||||
|
const duplicate = { ...structuredClone(original), id: "legacy-duplicate", clientId: "oaiapp_legacy" };
|
||||||
|
duplicate.tokens.idToken = await signed(duplicate.clientId, "legacy-nonce", original.subject);
|
||||||
|
const distinct = { ...structuredClone(original), id: "different-subject", clientId: "oaiapp_distinct", subject: "different-verified-subject" };
|
||||||
|
distinct.tokens.idToken = await signed(distinct.clientId, "legacy-nonce", distinct.subject);
|
||||||
|
state.registrations.push(duplicate, distinct);
|
||||||
|
state.activeId = duplicate.id;
|
||||||
|
const f = fixture({ state });
|
||||||
|
try {
|
||||||
|
const status = await f.service.status();
|
||||||
|
assert.equal(status.accounts.length, 2, "same issuer+subject groups; equal emails alone do not");
|
||||||
|
assert.equal(status.activeId, duplicate.id, "private transport keeps the actual active registration");
|
||||||
|
assert(status.accounts.some((a: any) => a.id === duplicate.id));
|
||||||
|
assert(status.accounts.some((a: any) => a.id === distinct.id));
|
||||||
|
assert.equal(f.saved.registrations.length, 3, "a status read never purges or merges stored credential handles");
|
||||||
|
assert.deepEqual(f.saved, state);
|
||||||
|
await f.service.select(distinct.id);
|
||||||
|
assert.equal((await f.service.status()).activeId, distinct.id);
|
||||||
|
assert.equal((await f.service.status()).accounts.length, 2);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit disconnect forgets all exact-identity registrations/handles, aborts active leases, and never auto-selects a same-email distinct account", async () => {
|
||||||
|
const first = await loggedIn(); first.service.close();
|
||||||
|
const state = structuredClone(first.saved), original = state.registrations[0];
|
||||||
|
const duplicate = { ...structuredClone(original), id: "duplicate-client", clientId: "oaiapp_duplicate", tokens: { ...original.tokens, refreshToken: "duplicate-refresh" } };
|
||||||
|
duplicate.tokens.idToken = await signed(duplicate.clientId, "historical", original.subject);
|
||||||
|
const other = { ...structuredClone(original), id: "distinct-account", clientId: "oaiapp_other", subject: "other-subject" };
|
||||||
|
other.tokens.idToken = await signed(other.clientId, "historical", other.subject);
|
||||||
|
state.registrations.push(duplicate, other);
|
||||||
|
const pushes: any[] = [], entered = deferred(), release = deferred();
|
||||||
|
let held = false;
|
||||||
|
const f = fixture({ state, change: (push) => pushes.push(push), provider: async (url) => {
|
||||||
|
if (url.endsWith("/revoke") && !held) { held = true; entered.resolve(); await release.promise; }
|
||||||
|
return undefined;
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
await f.service.status(); const catalog = await f.service.models();
|
||||||
|
const expected = { expectedAccountId: state.activeId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
|
||||||
|
await f.rawLease(false, expected);
|
||||||
|
const removing = f.service.disconnect(original.id); await entered.promise;
|
||||||
|
assert(pushes.some((push) => push.invalidate === "all"));
|
||||||
|
assert.equal((await f.service.status()).accounts.find((a: any) => a.id === original.id).connected, false);
|
||||||
|
release.resolve(); const result = await removing;
|
||||||
|
assert.equal(result.activeId, null); assert.equal(result.accounts.length, 1); assert.equal(result.accounts[0].id, other.id);
|
||||||
|
assert.deepEqual(f.saved.registrations, [other], "all duplicate handles gone; other verified subject untouched");
|
||||||
|
const revocations = f.requests.filter((r) => r.form?.has("token_type_hint"));
|
||||||
|
assert.equal(revocations.length, 2); assert(revocations.some((r) => r.form!.get("client_id") === duplicate.clientId && r.form!.get("token") === "duplicate-refresh"));
|
||||||
|
await assert.rejects(f.rawLease(false, expected));
|
||||||
|
await assert.rejects(f.service.select(original.id));
|
||||||
|
assert.equal(f.saved.activeId, null, "no automatic account/provider fallback");
|
||||||
|
} finally { release.resolve(); f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnecting an inactive identity preserves the active identity/credentials/catalog authority", async () => {
|
||||||
|
const first = await loggedIn(); first.service.close();
|
||||||
|
const state = structuredClone(first.saved), original = state.registrations[0];
|
||||||
|
const other = { ...structuredClone(original), id: "inactive-account", clientId: "oaiapp_inactive", subject: "inactive-subject" };
|
||||||
|
other.tokens.idToken = await signed(other.clientId, "historical", other.subject);
|
||||||
|
state.registrations.push(other); const f = fixture({ state });
|
||||||
|
try {
|
||||||
|
await f.service.status(); const catalog = await f.service.models(); const before = f.service.stamp();
|
||||||
|
await f.service.disconnect(other.id);
|
||||||
|
assert.equal(f.saved.activeId, original.id); assert.deepEqual(f.saved.registrations, [original]);
|
||||||
|
assert.equal(f.service.stamp().epoch, before.epoch); assert.equal(f.service.stamp().authorityRevision, before.authorityRevision);
|
||||||
|
assert.equal((await f.rawLease(false, { expectedAccountId: original.id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, original.id);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed-out legacy registrations can be explicitly forgotten; failed revocation never retains forgotten handles", async () => {
|
||||||
|
const first = await loggedIn(); await first.service.logout(first.saved.activeId); first.service.close();
|
||||||
|
const state = structuredClone(first.saved), duplicate = { ...structuredClone(state.registrations[0]), id: "signed-out-duplicate", clientId: "oaiapp_old" };
|
||||||
|
state.registrations.push(duplicate);
|
||||||
|
const f = fixture({ state });
|
||||||
|
try {
|
||||||
|
const status = await f.service.status(); assert.equal(status.accounts.length, 1); assert.equal(status.accounts[0].connected, false);
|
||||||
|
const result = await f.service.disconnect(status.accounts[0].id);
|
||||||
|
assert.equal(result.accounts.length, 0); assert.equal(result.activeId, null); assert.equal(f.saved.registrations.length, 0);
|
||||||
|
assert.equal(result.error, "Account disconnected locally. Remote revocation could not be confirmed.");
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
const unconfirmed = await loggedIn({ provider: async (url) => url.endsWith("/revoke") ? new Response(null, { status: 400 }) : undefined });
|
||||||
|
try {
|
||||||
|
const result = await unconfirmed.service.disconnect(unconfirmed.saved.activeId);
|
||||||
|
assert.equal(result.error, "Account disconnected locally. Remote revocation could not be confirmed."); assert.deepEqual(unconfirmed.saved.registrations, []);
|
||||||
|
assert.equal(unconfirmed.saved.activeId, null); assert.equal(JSON.stringify(unconfirmed.saved).includes("scratch-refresh"), false);
|
||||||
|
} finally { unconfirmed.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnect racing refresh revokes the latest rotated handle before forgetting, never its stale predecessor", async () => {
|
||||||
|
const entered = deferred(), release = deferred();
|
||||||
|
const f = await loggedIn({ token: async (form, url) => {
|
||||||
|
if (form.get("grant_type") === "refresh_token") {
|
||||||
|
entered.resolve(); await release.promise;
|
||||||
|
return Response.json({ access_token: "latest-access", refresh_token: "latest-refresh", token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
|
||||||
|
}
|
||||||
|
return Response.json({ access_token: "scratch-access", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
const lease = f.service.lease(false); const rejected = assert.rejects(lease); await entered.promise;
|
||||||
|
const forgetting = f.service.disconnect(f.saved.activeId); release.resolve(); await rejected; await forgetting;
|
||||||
|
const revoked = f.requests.filter((r) => r.form?.has("token_type_hint"));
|
||||||
|
assert.equal(revoked.length, 1); assert.equal(revoked[0]!.form!.get("token"), "latest-refresh");
|
||||||
|
assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.activeId, null);
|
||||||
|
} finally { release.resolve(); f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnect persistence failure is not reported as local forgetting and leaves encrypted state fail-closed", async () => {
|
||||||
|
let failWrite = false;
|
||||||
|
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
|
||||||
|
try {
|
||||||
|
const before = structuredClone(f.saved); failWrite = true;
|
||||||
|
await assert.rejects(f.service.disconnect(f.saved.activeId));
|
||||||
|
assert.deepEqual(f.saved, before); assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||||
|
await assert.rejects(f.service.lease(false));
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations", async () => {
|
||||||
|
const first = await loggedIn(); first.service.close();
|
||||||
|
const legacy = structuredClone(first.saved); legacy.registrations[0].background = false;
|
||||||
|
const f = fixture({ state: legacy });
|
||||||
|
try {
|
||||||
|
assert.equal((await f.service.status()).allowBackgroundUse, true, "absent new setting ignores legacy per-account OFF");
|
||||||
|
await f.service.lease(true);
|
||||||
|
await f.service.consent(false);
|
||||||
|
assert.equal(f.saved.allowBackgroundUse, false); assert.equal(f.saved.registrations[0].background, false);
|
||||||
|
await assert.rejects(f.service.lease(true), /background/);
|
||||||
|
await f.service.lease(false);
|
||||||
|
const id = f.saved.activeId;
|
||||||
|
await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false);
|
||||||
|
await f.service.login(id); await f.callback();
|
||||||
|
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference");
|
||||||
|
assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference");
|
||||||
|
await f.service.disconnect(f.saved.activeId);
|
||||||
|
assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.allowBackgroundUse, false);
|
||||||
|
const restarted = fixture({ state: f.saved });
|
||||||
|
try {
|
||||||
|
assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF");
|
||||||
|
await restarted.service.login(); await restarted.callback();
|
||||||
|
for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(restarted.saved.allowBackgroundUse, false);
|
||||||
|
await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true);
|
||||||
|
} finally { restarted.service.close(); }
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("private leases bind active account, authority revision and raw visible model membership", async () => {
|
||||||
|
const f = await loggedIn();
|
||||||
|
try {
|
||||||
|
const catalog = await f.service.models();
|
||||||
|
const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
|
||||||
|
await assert.rejects(f.rawLease(false));
|
||||||
|
for (const mutation of [{ expectedAccountId: "another" }, { expectedAuthorityRevision: catalog.authorityRevision - 1 }, { selectedModelId: "hidden" }]) await assert.rejects(f.rawLease(false, { ...expected, ...mutation }));
|
||||||
|
assert.equal((await f.rawLease(false, expected)).accountId, catalog.accountId);
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
|
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
||||||
|
import { piModels } from "../src/agent/models.ts";
|
||||||
|
import { toLlmMessages } from "../src/core/native-messages.ts";
|
||||||
|
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chatgpt-background-"));
|
||||||
|
process.env.LH_DATA_DIR = path.join(dir, "data");
|
||||||
|
process.env.LH_AUTH_FILE = path.join(dir, "scratch-auth.json");
|
||||||
|
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
|
||||||
|
const { stores } = await import("../src/server/stores.ts");
|
||||||
|
const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
|
||||||
|
const { deliverJobResult, registerLiveAgent, unregisterLiveAgent, setTurnRunner, settleSteers, steerLiveChat } = await import("../src/server/jobs.ts");
|
||||||
|
const { persistTurn } = await import("../src/server/turn-service.ts");
|
||||||
|
const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
|
||||||
|
let revision = 1;
|
||||||
|
function connection(background: boolean) {
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } });
|
||||||
|
}
|
||||||
|
after(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
for (const revokedAfterQueue of [false, true]) {
|
||||||
|
test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => {
|
||||||
|
connection(revokedAfterQueue);
|
||||||
|
const chat = stores.chatStore.createChat("Scratch mixed queue");
|
||||||
|
stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
|
||||||
|
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
|
||||||
|
const result = { brief: "Scratch completed job result", sources: [] };
|
||||||
|
stores.jobStore.updateJob(job.id, { status: "running", result });
|
||||||
|
const marker = buildSubagentSteer(job, result);
|
||||||
|
const contexts: string[] = [];
|
||||||
|
const native = piModels().getModel("openai", "gpt-5")!;
|
||||||
|
const model = { ...native, provider: "chatgpt" };
|
||||||
|
const agent = new Agent({ initialState: { model, messages: [{ role: "user", content: "Initial scratch learner input", timestamp: 1, id: "scratch-initial" } as AgentMessage] }, convertToLlm: toLlmMessages,
|
||||||
|
streamFn: () => {
|
||||||
|
contexts.push(JSON.stringify(agent.state.messages));
|
||||||
|
const stream = new AssistantMessageEventStream();
|
||||||
|
const output = { role: "assistant" as const, provider: "chatgpt", api: model.api, model: model.id, content: [{ type: "text" as const, text: "Scratch foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||||
|
stream.push({ type: "start", partial: output }); stream.push({ type: "done", reason: "stop", message: output }); stream.end();
|
||||||
|
return stream;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let wakes = 0;
|
||||||
|
setTurnRunner(async (chatId, input) => {
|
||||||
|
wakes++;
|
||||||
|
assert.equal(input.type, "steers");
|
||||||
|
if (input.type === "steers") stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...input.steers]);
|
||||||
|
return 200;
|
||||||
|
});
|
||||||
|
assert(beginTurn(chat.id));
|
||||||
|
registerLiveAgent(chat.id, agent);
|
||||||
|
const delivered = deliverJobResult(marker);
|
||||||
|
const learner = { id: "scratch-live-learner", role: "user", content: "Please continue my learning", timestamp: 3 } as never;
|
||||||
|
assert(steerLiveChat(chat.id, learner));
|
||||||
|
if (revokedAfterQueue) connection(false);
|
||||||
|
await agent.continue();
|
||||||
|
assert.equal(contexts.length, 1, "ordinary foreground tool-free call remains usable");
|
||||||
|
assert(contexts[0]!.includes("Please continue my learning"));
|
||||||
|
assert(!contexts[0]!.includes(marker.id), "automatic marker must not enter the foreground batch");
|
||||||
|
assert(!agent.hasQueuedMessages());
|
||||||
|
// Ordinary lease/catalog replies are not consent changes and must never
|
||||||
|
// reset/replay a learner steer that the agent has already consumed.
|
||||||
|
ipc({ channel: "lh:chatgpt", id: "ordinary-reply-with-no-pending-call", value: { accessToken: "scratch-only" } });
|
||||||
|
assert(!agent.hasQueuedMessages());
|
||||||
|
const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
|
||||||
|
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(wakes, 0, "no background spin or spend while consent is absent");
|
||||||
|
assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral");
|
||||||
|
assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
|
||||||
|
connection(true);
|
||||||
|
await delivered;
|
||||||
|
assert.equal(wakes, 1, "explicit consent resumes the preserved result exactly once");
|
||||||
|
assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { piModels } from "../src/agent/models.ts";
|
||||||
|
import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts";
|
||||||
|
const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
|
const pending: any[] = [];
|
||||||
|
const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!;
|
||||||
|
const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m);
|
||||||
|
const status = (account: string, background = true) => ({ available: true, pending: false, allowBackgroundUse: background, activeId: account, accounts: [{ id: account, connected: true, background: !background, planEnabled: true }], error: null });
|
||||||
|
const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] });
|
||||||
|
function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
|
||||||
|
before(() => {
|
||||||
|
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||||
|
Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } });
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
if (send) Object.defineProperty(process, "send", send); else delete process.send;
|
||||||
|
if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
|
||||||
|
});
|
||||||
|
test("overlapping catalog requests only install the newest generation", async () => {
|
||||||
|
const old = refreshChatGPTModels([model as never]), latest = refreshChatGPTModels([model as never]);
|
||||||
|
reply(pending[1], value("newest", 2)); await latest;
|
||||||
|
reply(pending[0], value("stale", 1)); await old;
|
||||||
|
assert.equal(chatGPTModels()[0]?.name, "newest");
|
||||||
|
});
|
||||||
|
test("consent-only status does not discard current-authority catalog or overwrite newer consent from an old status embedded in models", async () => {
|
||||||
|
const refresh = refreshChatGPTModels([model as never]); const request = pending.at(-1);
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 3 }, status: status("A", false), invalidate: "background" });
|
||||||
|
reply(request, value("same authority", 2)); await refresh;
|
||||||
|
assert.equal(chatGPTModels()[0]?.name, "same authority"); assert.equal(chatGPTStatus().allowBackgroundUse, false);
|
||||||
|
const scope = chatGPTScope(model.id); assert(chatGPTScopeCurrent(scope));
|
||||||
|
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 4 }, status: status("A", false), invalidate: "background" });
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 5 }, status: status("A", true), invalidate: "none" });
|
||||||
|
assert(!foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert(chatGPTScopeCurrent(scope));
|
||||||
|
foreground.release(); background.release();
|
||||||
|
});
|
||||||
|
test("account/full and catalog authority changes reject outstanding old leases without silently switching identity", async () => {
|
||||||
|
const scope = chatGPTScope(model.id); const lease = chatGPTLease(scope, false); const request = pending.at(-1);
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 6 }, status: status("B"), invalidate: "all" });
|
||||||
|
assert.equal(chatGPTModels().length, 0); assert(!chatGPTScopeCurrent(scope));
|
||||||
|
reply(request, { epoch: 1, authorityRevision: 1, revision: 5, accountId: "A", accessToken: "dummy-old" });
|
||||||
|
await assert.rejects(lease, /stale/);
|
||||||
|
const refresh = refreshChatGPTModels([model as never]); reply(pending.at(-1), value("B", 7, "B", 2)); await refresh;
|
||||||
|
const newScope = chatGPTScope(model.id); assert.equal(newScope.accountId, "B");
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 3, revision: 8 }, status: status("B"), invalidate: "none" });
|
||||||
|
assert.equal(chatGPTModels().length, 0); await assert.rejects(chatGPTLease(newScope, false));
|
||||||
|
});
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, test } from "node:test";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
||||||
|
import { ChatGPTBackgroundAccess, ChatGPTConnection, ChatGPTUIProvider, CHATGPT_NOTICE, CHATGPT_DISCONNECT_COPY } from "../components/settings/chatgpt-connection.tsx";
|
||||||
|
import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx";
|
||||||
|
import { KeysSection } from "../components/settings/keys-section.tsx";
|
||||||
|
import { ModelControl } from "../components/model-picker.tsx";
|
||||||
|
import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
|
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||||
|
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected });
|
||||||
|
const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
|
||||||
|
const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
|
||||||
|
const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
|
||||||
|
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
|
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
|
||||||
|
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||||
|
function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
|
||||||
|
let status = initial;
|
||||||
|
const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
function push(next: ChatGPTStatus) { status = next; for (const cb of listeners) cb(); }
|
||||||
|
const bridge: ChatGPTControlBridge = {
|
||||||
|
status: async () => status,
|
||||||
|
login: async (id) => { calls.login.push(id); push({ ...status, pending: true, error: null }); return status; },
|
||||||
|
cancel: async () => { calls.cancel++; push({ ...status, pending: false, error: null }); return status; },
|
||||||
|
select: async (id) => { calls.select.push(id); push({ ...status, activeId: id }); return status; },
|
||||||
|
disconnect: async (id) => { calls.disconnect.push(id); push({ ...status, activeId: status.activeId === id ? null : status.activeId, accounts: status.accounts.filter((a) => a.id !== id) }); return status; },
|
||||||
|
logout: async () => { calls.logout++; return status; },
|
||||||
|
consent: async (enabled) => { calls.consent.push(enabled); push({ ...status, allowBackgroundUse: enabled }); return status; },
|
||||||
|
onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, ...overrides,
|
||||||
|
};
|
||||||
|
window.lh = { expandWindow() {}, chatgpt: bridge };
|
||||||
|
return { calls, bridge, push, get status() { return status; }, complete() { push({ ...connected(status.allowBackgroundUse), pending: false }); } };
|
||||||
|
}
|
||||||
|
async function mount(children: React.ReactNode = <ChatGPTConnection />) {
|
||||||
|
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||||
|
await act(async () => root!.render(<ChatGPTUIProvider>{children}</ChatGPTUIProvider>));
|
||||||
|
}
|
||||||
|
const byTest = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}"]`)!;
|
||||||
|
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
|
||||||
|
function button(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((el) => el.textContent === text)!; }
|
||||||
|
const toggle = () => document.querySelector<HTMLElement>('[data-testid="chatgpt-account-dialog"] [role="switch"]')!;
|
||||||
|
async function selectAccount(label: string, id: string) {
|
||||||
|
const select = document.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`)!; assert(select);
|
||||||
|
await act(async () => { Object.defineProperty(select, "value", { configurable: true, writable: true, value: id }); select.dispatchEvent(new window.Event("change", { bubbles: true })); });
|
||||||
|
}
|
||||||
|
async function begin() { await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed")); }
|
||||||
|
async function confirmDisconnect() { await click(byTest("chatgpt-disconnect")); await click(byTest("chatgpt-confirm-disconnect")); }
|
||||||
|
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; globalThis.fetch = originalFetch; });
|
||||||
|
|
||||||
|
test("Providers is the first/default page; General is last and other pages keep relative order", () => {
|
||||||
|
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
|
||||||
|
const ids = [...html.matchAll(/data-testid="settings-nav-([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
assert.deepEqual(ids, ["keys", "agent", "appearance", "graph", "keyboard", "general"]);
|
||||||
|
assert.match(html, /settings-nav-keys[^>]*aria-current="true"/); assert.match(html, />Providers<\/button>/);
|
||||||
|
assert.doesNotMatch(html, />Model providers<\/button>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
assert.equal(url, "/api/keys");
|
||||||
|
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
|
||||||
|
};
|
||||||
|
fixture(); await mount(<KeysSection />);
|
||||||
|
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
|
||||||
|
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
|
||||||
|
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
|
||||||
|
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
|
||||||
|
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
|
||||||
|
assert(byTest("unknown-FUTURE_VAR").closest('[data-testid="group-provider-keys"]'));
|
||||||
|
assert(byTest("group-unknown-keys").parentElement?.lastElementChild === byTest("group-unknown-keys"));
|
||||||
|
assert.equal(byTest("unknown-FUTURE_VAR").textContent, "FUTURE_VAR…abcd"); assert(!byTest("unknown-FUTURE_VAR").querySelector("input, button"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disconnected row is only secondary Continue; initial default is ON; cancel discards draft without OAuth/global mutation", async () => {
|
||||||
|
const f = fixture(); await mount(<ChatGPTConnection setup />);
|
||||||
|
assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
|
||||||
|
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); const trigger = byTest("chatgpt-login");
|
||||||
|
assert.match(trigger.className, /cursor-pointer/); assert.match(trigger.className, /hover:bg-accent/); assert.match(trigger.className, /focus-visible:ring/);
|
||||||
|
await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
|
assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
|
||||||
|
assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
|
||||||
|
await click(toggle());
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
assert.equal(f.calls.login.length, 0); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const value of [true, false]) test(`untouched initial sign-in preserves existing global ${value} through verified completion`, async () => {
|
||||||
|
const f = fixture(disconnected(value)); await mount(); await click(byTest("chatgpt-login"));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), String(value)); await click(byTest("chatgpt-proceed"));
|
||||||
|
assert(document.body.textContent?.includes("Waiting for browser sign-in")); assert.deepEqual(f.calls.consent, []);
|
||||||
|
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, value);
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…"));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const value of [true, false]) test(`explicit global draft ${!value} is applied only after actual pending/onChange verified completion`, async () => {
|
||||||
|
const f = fixture(disconnected(value)); await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
|
||||||
|
assert.deepEqual(f.calls.consent, []); assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled);
|
||||||
|
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, [!value]); assert.equal(f.status.allowBackgroundUse, !value);
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
await act(async () => f.push(f.status)); assert.deepEqual(f.calls.consent, [!value]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cancel after initial IPC promise ends cancels actual OAuth; late completion cannot apply draft or close another dialog", async () => {
|
||||||
|
const f = fixture(); await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1);
|
||||||
|
await click(byTest("chatgpt-login")); const dialog = byTest("chatgpt-account-dialog");
|
||||||
|
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog") === dialog); assert.equal(f.calls.login.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("review: late cancel acknowledgement must not roll back newer global authority", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(disconnected(true), { cancel: async () => { f.calls.cancel++; return held.promise; } });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(false)));
|
||||||
|
await act(async () => held.resolve(disconnected(true)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
assert.equal(f.status.allowBackgroundUse, false, "backend truth remains OFF");
|
||||||
|
await click(byTest("chatgpt-login"));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), "false", "new disclosure must use the newer authoritative global OFF, not the old cancellation snapshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late cancel acknowledgement cannot roll newer global ON back to OFF", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(false), { cancel: () => held.promise });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(true))); await act(async () => held.resolve(disconnected(false)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert.equal(f.status.allowBackgroundUse, true);
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel reconciliation read is itself fenced when a newer status arrives before that read returns", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(), current = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(disconnected(), { cancel: () => held.promise }); let reads = 0;
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(false)));
|
||||||
|
f.bridge.status = () => { reads++; return current.promise; };
|
||||||
|
await act(async () => held.resolve(disconnected(true))); assert.equal(reads, 1);
|
||||||
|
f.bridge.status = async () => f.status;
|
||||||
|
await act(async () => f.push(disconnected(true))); await act(async () => current.resolve(disconnected(false)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late cancellation preserves newer availability and error instead of dismissing an unrelated failure", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { cancel: () => held.promise });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push({ ...disconnected(false), available: false, error: storageError }));
|
||||||
|
await act(async () => held.resolve(disconnected(true)));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert((byTest("chatgpt-login") as HTMLButtonElement).disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only cancellation reconciliation keeps last valid authority and leaves cancellation recoverable", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { cancel: () => held.promise });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await act(async () => f.push(disconnected(false)));
|
||||||
|
f.bridge.status = async () => errorReply(); await act(async () => held.resolve(disconnected(true)));
|
||||||
|
assert(byTest("chatgpt-account-dialog")); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
|
||||||
|
assert.equal((button("Cancel", byTest("chatgpt-account-dialog")) as HTMLButtonElement).disabled, false);
|
||||||
|
f.bridge.status = async () => f.status; f.bridge.cancel = async () => f.status;
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "false"); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("email-only connected selector and Manage remain responsive; account selection never changes global OFF", async () => {
|
||||||
|
const initial = connected(false); initial.accounts.push(account("second", "second@example.invalid"), account("missing", ""));
|
||||||
|
const f = fixture(initial); await mount();
|
||||||
|
assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
|
||||||
|
assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
|
||||||
|
assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
|
||||||
|
await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
|
||||||
|
await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
|
||||||
|
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
|
||||||
|
const dialog = byTest("chatgpt-account-dialog").innerHTML;
|
||||||
|
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
|
||||||
|
assert(dialog.indexOf('data-testid="chatgpt-account-actions"') < dialog.indexOf('data-testid="chatgpt-global-background"'));
|
||||||
|
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
|
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
|
||||||
|
assert.equal(f.calls.logout, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Disconnect account stays under identity, outside the nondestructive Manage footer; confirmation Cancel never forgets", async () => {
|
||||||
|
assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
|
||||||
|
const f = fixture(connected()); await mount(); await click(button("Manage…"));
|
||||||
|
assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect account"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
|
||||||
|
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect")));
|
||||||
|
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
|
||||||
|
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
|
||||||
|
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
|
||||||
|
assert(!document.body.textContent?.includes("delete your OpenAI"));
|
||||||
|
await click(button("Cancel", byTest("chatgpt-disconnect-confirmation"))); assert(byTest("chatgpt-disconnect")); assert.deepEqual(f.calls.disconnect, []);
|
||||||
|
await click(button("Close")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with ${count} saved accounts; opening and cancelling preserve the connection`, async () => {
|
||||||
|
const initial = connected(count === 3);
|
||||||
|
initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))];
|
||||||
|
const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…"));
|
||||||
|
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
|
||||||
|
assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "outline");
|
||||||
|
assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/);
|
||||||
|
await click(byTest("chatgpt-add-accounts"));
|
||||||
|
assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed"));
|
||||||
|
assert.equal(document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')?.value, "");
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), String(before.allowBackgroundUse));
|
||||||
|
assert.deepEqual(f.status, before); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.select, []); assert.deepEqual(f.calls.disconnect, []);
|
||||||
|
await click(toggle()); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
assert.deepEqual(f.status, before); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
|
||||||
|
await click(button("Manage…")); await click(byTest("chatgpt-add-accounts"));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), String(before.allowBackgroundUse));
|
||||||
|
await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.login, [undefined], "existing login machinery receives no saved/current account ID");
|
||||||
|
assert.equal(f.status.activeId, before.activeId); assert.deepEqual(f.status.accounts, before.accounts);
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => {
|
||||||
|
const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = [];
|
||||||
|
HTMLElement.prototype.focus = function () { focused.push(this); };
|
||||||
|
try {
|
||||||
|
fixture(connected()); await mount(); await click(button("Manage…")); await click(byTest("chatgpt-add-accounts"));
|
||||||
|
assert(focused.at(-1) === document.querySelector('select[aria-label="Saved ChatGPT account"]'));
|
||||||
|
} finally { HTMLElement.prototype.focus = previous; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Add accounts respects existing busy, pending and unavailable guards", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(connected(), { consent: () => held.promise });
|
||||||
|
await mount(); await click(button("Manage…")); await click(toggle());
|
||||||
|
assert((byTest("chatgpt-add-accounts") as HTMLButtonElement).disabled);
|
||||||
|
await click(byTest("chatgpt-add-accounts")); assert(byTest("chatgpt-managed-identity")); assert(!document.querySelector('[data-testid="chatgpt-proceed"]'));
|
||||||
|
await act(async () => held.resolve(f.status)); assert.equal((byTest("chatgpt-add-accounts") as HTMLButtonElement).disabled, false);
|
||||||
|
await act(async () => f.push({ ...f.status, pending: true })); assert((byTest("chatgpt-add-accounts") as HTMLButtonElement).disabled);
|
||||||
|
await act(async () => f.push({ ...f.status, pending: false, available: false })); assert((byTest("chatgpt-add-accounts") as HTMLButtonElement).disabled);
|
||||||
|
assert.deepEqual(f.calls.login, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirmed disconnect preserves identity/pending content until outcome, prevents duplicate clicks, then closes without selecting another account", async () => {
|
||||||
|
const done = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(connected(false), { disconnect: (id) => { f.calls.disconnect.push(id); return done.promise; } });
|
||||||
|
await mount(); await click(button("Manage…")); await confirmDisconnect();
|
||||||
|
await click(byTest("chatgpt-confirm-disconnect")); assert.deepEqual(f.calls.disconnect, ["private-registration-id"]);
|
||||||
|
const final = { ...disconnected(false), accounts: [account("other", "other@example.invalid")] };
|
||||||
|
await act(async () => f.push(final));
|
||||||
|
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes("learner@example.invalid"));
|
||||||
|
assert((byTest("chatgpt-confirm-disconnect") as HTMLButtonElement).disabled); assert.equal(byTest("chatgpt-account-dialog").getAttribute("aria-busy"), "true");
|
||||||
|
await act(async () => done.resolve(final));
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(byTest("chatgpt-login")); assert.equal(f.status.activeId, null);
|
||||||
|
assert.equal(f.status.allowBackgroundUse, false); assert.equal(f.calls.logout, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed-out legacy accounts remain manageable/forgettable without pretending selection is a usable connection", async () => {
|
||||||
|
const f = fixture(); await mount(); assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
|
||||||
|
await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "signed-out-record"); await click(button("Manage…"));
|
||||||
|
assert(byTest("chatgpt-managed-identity").textContent?.includes("signed-out@example.invalid"));
|
||||||
|
assert.deepEqual(f.calls.select, []); assert.equal(f.calls.login.length, 0); await confirmDisconnect();
|
||||||
|
assert.deepEqual(f.calls.disconnect, ["signed-out-record"]); assert.equal(f.status.accounts.length, 0); assert.equal(f.status.allowBackgroundUse, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unconfirmed provider outcome is exactly the approved concise dismissible warning, never permanent red success/navigation advice", async () => {
|
||||||
|
const f = fixture(connected(), { disconnect: async () => { const result = { ...disconnected(), accounts: [], error: warning }; f.push(result); return result; } });
|
||||||
|
await mount(); await click(button("Manage…")); await confirmDisconnect();
|
||||||
|
const alert = byTest("chatgpt-revocation-warning"); assert.equal(alert.textContent, warning); assert.doesNotMatch(alert.className, /text-red/);
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-disconnect"]')); assert(byTest("chatgpt-login"));
|
||||||
|
await click(button("Dismiss", byTest("chatgpt-account-dialog"))); assert(!document.querySelector('[role="alert"]'));
|
||||||
|
assert(!document.body.textContent?.includes("ChatGPT Settings"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dismissed revocation warning stays dismissed across global setting changes but a new unconfirmed disconnect is shown", async () => {
|
||||||
|
const initial = connected(); initial.error = warning;
|
||||||
|
const f = fixture(initial, { disconnect: async () => { const next = { ...disconnected(false), accounts: [], error: warning }; f.push(next); return next; } });
|
||||||
|
await mount(); await click(button("Dismiss")); await click(button("Manage…")); await click(toggle());
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!document.querySelector('[role="alert"]'));
|
||||||
|
await confirmDisconnect(); assert.equal(byTest("chatgpt-revocation-warning").textContent, warning);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contextual Enable action opens the same global control without silently enabling; no foreground/provider clutter", async () => {
|
||||||
|
const f = fixture(connected(false)); await mount(<><ChatGPTBackgroundAccess provider="chatgpt" /><ChatGPTBackgroundAccess provider="openai" /><ChatGPTConnection /></>);
|
||||||
|
assert.equal([...document.querySelectorAll("button")].filter((b) => b.textContent === "Enable background use").length, 1);
|
||||||
|
await click(button("Enable background use")); assert.deepEqual(f.calls.consent, []);
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), "false"); await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert(!button("Enable background use"));
|
||||||
|
await act(async () => f.push(disconnected(false))); assert(!button("Enable background use"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only disconnect reply preserves coherent status/confirmation and permits explicit retry, not false forgotten/connected success", async () => {
|
||||||
|
const f = fixture(connected(), { disconnect: async () => errorReply() }); await mount(); await click(button("Manage…"));
|
||||||
|
await assert.doesNotReject(confirmDisconnect); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
|
||||||
|
assert.equal((byTest("chatgpt-confirm-disconnect") as HTMLButtonElement).disabled, false); assert(byTest("chatgpt-disconnect-confirmation"));
|
||||||
|
f.bridge.disconnect = async () => { const next = { ...disconnected(), accounts: [] }; f.push(next); return next; };
|
||||||
|
await click(byTest("chatgpt-confirm-disconnect")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(byTest("chatgpt-login"));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const value of [true, false]) test(`global toggle keeps its normal cursor while saving ${!value}, without accepting duplicate clicks`, async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>();
|
||||||
|
const f = fixture(connected(value), { consent: (enabled) => { f.calls.consent.push(enabled); return held.promise; } });
|
||||||
|
await mount(); await click(button("Manage…")); await click(toggle());
|
||||||
|
await act(async () => f.push(connected(!value)));
|
||||||
|
assert((toggle() as HTMLButtonElement).disabled);
|
||||||
|
assert(byTest("chatgpt-global-background").className.includes("[&>button:disabled]:cursor-auto"));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), String(!value));
|
||||||
|
await click(toggle()); assert.deepEqual(f.calls.consent, [!value]);
|
||||||
|
await act(async () => held.resolve(connected(!value)));
|
||||||
|
assert.equal((toggle() as HTMLButtonElement).disabled, false);
|
||||||
|
assert(!byTest("chatgpt-global-background").className.includes("cursor-auto"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global toggle retains the unavailable cursor if secure availability is lost during a save", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(connected(), { consent: () => held.promise });
|
||||||
|
await mount(); await click(button("Manage…")); await click(toggle());
|
||||||
|
const denied = connected(false); denied.available = false; denied.error = storageError;
|
||||||
|
denied.accounts = denied.accounts.map((a) => ({ ...a, connected: false, planEnabled: false }));
|
||||||
|
await act(async () => f.push(denied));
|
||||||
|
assert((toggle() as HTMLButtonElement).disabled); assert.match(toggle().className, /cursor-not-allowed/);
|
||||||
|
assert(!byTest("chatgpt-global-background").className.includes("cursor-auto"));
|
||||||
|
await act(async () => held.resolve(connected(false)));
|
||||||
|
assert((toggle() as HTMLButtonElement).disabled); assert(!byTest("chatgpt-global-background").className.includes("cursor-auto"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only global consent reply keeps saved state visible and a recoverable error", async () => {
|
||||||
|
fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…"));
|
||||||
|
await assert.doesNotReject(() => click(toggle())); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
|
||||||
|
assert.equal((toggle() as HTMLButtonElement).disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only login reply releases its operation without corrupting status or auto-retrying", async () => {
|
||||||
|
let calls = 0; fixture(disconnected(), { login: async () => { calls++; return errorReply(); } }); await mount(); await assert.doesNotReject(begin);
|
||||||
|
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false); assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only initial global draft save leaves disclosure/error and duplicate completion cannot retry the setting", async () => {
|
||||||
|
let calls = 0; const f = fixture(disconnected(), { consent: async () => { calls++; return errorReply(); } });
|
||||||
|
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
|
||||||
|
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog"));
|
||||||
|
await act(async () => f.push(f.status)); assert.equal(calls, 1); assert.equal(f.calls.login.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error-only pending cancellation remains retryable and cannot apply a global draft", async () => {
|
||||||
|
const f = fixture(disconnected(), { cancel: async () => { f.calls.cancel++; return errorReply(); } });
|
||||||
|
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
|
||||||
|
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog"));
|
||||||
|
f.bridge.cancel = async () => { f.calls.cancel++; const next = { ...f.status, pending: false }; f.push(next); return next; };
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 2); assert.deepEqual(f.calls.consent, []);
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed pending verification never applies draft/starts login/closes disclosure on a later clean status", async () => {
|
||||||
|
const f = fixture(); await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
|
||||||
|
await act(async () => f.push({ ...connected(), error: "ChatGPT sign-in could not be verified." }));
|
||||||
|
await act(async () => f.push(connected())); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login.length, 1); assert(byTest("chatgpt-account-dialog"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved-registration mismatch cannot complete the attempt or apply its global draft", async () => {
|
||||||
|
const f = fixture(); await mount(); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "signed-out-record");
|
||||||
|
await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
|
||||||
|
assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.login, ["signed-out-record"]); assert(document.querySelector('[role="alert"]')); assert(byTest("chatgpt-account-dialog"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("account invalidation while initial global consent awaits IPC fences stale reply and preserves an unrelated later dialog", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { consent: () => held.promise });
|
||||||
|
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
|
||||||
|
const other = { ...connected(), activeId: "other", accounts: [account("other", "other@example.invalid")] }; await act(async () => f.push(other));
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await click(button("Manage…")); const dialog = byTest("chatgpt-account-dialog");
|
||||||
|
await act(async () => held.resolve(connected(false))); assert(byTest("chatgpt-account-dialog") === dialog);
|
||||||
|
assert(dialog.textContent?.includes("other@example.invalid")); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late global-control reply cannot overwrite a newer authoritative global value or leave control stuck", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(connected(), { consent: () => held.promise });
|
||||||
|
await mount(); await click(button("Manage…")); await click(toggle());
|
||||||
|
await act(async () => { f.push(connected(false)); f.push(connected(true)); });
|
||||||
|
await act(async () => held.resolve(connected(false)));
|
||||||
|
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal((toggle() as HTMLButtonElement).disabled, false);
|
||||||
|
await click(button("Close")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late initial global draft reply cannot resurrect an older global value after a newer authoritative setting", async () => {
|
||||||
|
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { consent: () => held.promise });
|
||||||
|
await mount(<><ChatGPTConnection /><ChatGPTBackgroundAccess provider="chatgpt" /></>);
|
||||||
|
await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
|
||||||
|
await act(async () => { f.push(connected(false)); f.push(connected(true)); });
|
||||||
|
await act(async () => held.resolve(connected(false)));
|
||||||
|
assert(!button("Enable background use")); assert(document.querySelector('[role="alert"]')); assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coalesced verified completion before late pending acknowledgement is reconciled once from fresh status", async () => {
|
||||||
|
const ack = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { login: () => { f.calls.login.push(undefined); return ack.promise; } });
|
||||||
|
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
|
||||||
|
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []);
|
||||||
|
await act(async () => ack.resolve({ ...disconnected(), pending: true })); assert.deepEqual(f.calls.consent, [false]); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canceled attempt's late reply cannot release a newer pending attempt or apply its old draft", async () => {
|
||||||
|
const old = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { login: async () => { f.calls.login.push(undefined); if (f.calls.login.length === 1) return old.promise; f.push({ ...f.status, pending: true }); return f.status; } });
|
||||||
|
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await begin(); await act(async () => old.resolve(connected(false)));
|
||||||
|
assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled); assert.deepEqual(f.calls.consent, []);
|
||||||
|
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => {
|
||||||
|
const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
|
||||||
|
const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" };
|
||||||
|
let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>();
|
||||||
|
const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => {
|
||||||
|
if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
|
||||||
|
assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++;
|
||||||
|
const form = init.body as URLSearchParams;
|
||||||
|
const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey);
|
||||||
|
return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
|
||||||
|
} });
|
||||||
|
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
|
||||||
|
try {
|
||||||
|
await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0);
|
||||||
|
await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
|
||||||
|
const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString();
|
||||||
|
await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); });
|
||||||
|
assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined);
|
||||||
|
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…"));
|
||||||
|
} finally { await act(async () => service.close()); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => {
|
||||||
|
await mount(); assert((byTest("chatgpt-login") as HTMLButtonElement).disabled); await click(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
|
||||||
|
await act(async () => root!.unmount()); host?.remove(); root = undefined;
|
||||||
|
globalThis.fetch = async (input) => {
|
||||||
|
if (input === "/api/models") return Response.json({ models: [], providers: [], slots: {}, defaults: {} });
|
||||||
|
if (input === "/api/settings") return Response.json({ settings: { models: { strong: { model: { provider: "chatgpt", id: "gpt-5" } } } } });
|
||||||
|
throw new Error(`unexpected fixture request ${input}`);
|
||||||
|
};
|
||||||
|
fixture(connected(false)); await mount(<ModelControl />); assert.match(byTest("stats-model").textContent!, /gpt/i);
|
||||||
|
assert(!document.body.textContent?.includes("Using ChatGPT plan")); assert(!document.body.textContent?.includes("Enable background use"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { type Model } from "@earendil-works/pi-ai";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
|
import { piModels, piEngineForSpec, APP_IDENTITY } from "../src/agent/models.ts";
|
||||||
|
import { refreshChatGPTModels, chatGPTScopeCurrent } from "../src/agent/chatgpt-bridge.ts";
|
||||||
|
import { ChatGPTRequestCycle } from "../src/agent/chatgpt-request-cycle.ts";
|
||||||
|
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chatgpt-native-"));
|
||||||
|
process.env.LH_DATA_DIR = path.join(scratch, "data");
|
||||||
|
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
|
||||||
|
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
|
||||||
|
const { runPiTurn } = await import("../src/server/pi/ui-stream.ts");
|
||||||
|
import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, reconstructRequestCause, concludedByDismissalPair, type LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
|
||||||
|
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
||||||
|
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
||||||
|
let epoch = 1, revision = 1, background = true;
|
||||||
|
let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
|
||||||
|
let leases: boolean[] = [], fetches = 0, toolFirst = false;
|
||||||
|
const status = () => ({ available: true, pending: false, allowBackgroundUse: background, activeId: "scratch", accounts: [{ id: "scratch", registration: "oaiapp_scratch", email: "dummy@example.invalid", connected: true, planEnabled: true, background: !background }], error: null });
|
||||||
|
const ipc = (m: unknown) => (process.emit as (name: string, m: unknown) => boolean)("message", m);
|
||||||
|
function revoke(full = false) {
|
||||||
|
background = false; if (full) epoch++;
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" });
|
||||||
|
}
|
||||||
|
before(() => {
|
||||||
|
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||||
|
Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => {
|
||||||
|
if (m.op === "lease") {
|
||||||
|
leases.push(m.background);
|
||||||
|
assert.equal(m.expectedAccountId, "scratch"); assert.equal(m.expectedAuthorityRevision, epoch); assert.equal(m.selectedModelId, model.id);
|
||||||
|
if (m.background && (cut === "lease" || cut === "full")) { const full = cut === "full"; cut = "none"; revoke(full); }
|
||||||
|
}
|
||||||
|
const value = { epoch, authorityRevision: epoch, revision, ...(m.op === "lease" ? { accountId: "scratch", accessToken: "dummy-oauth-native" } : { status: status(), accountId: "scratch", models: [{ id: model.id, name: "Scratch" }] }) };
|
||||||
|
queueMicrotask(() => ipc({ channel: "lh:chatgpt", id: m.id, op: m.op, ok: true, value })); callback?.(); return true;
|
||||||
|
} });
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const request = new Request(input, init);
|
||||||
|
assert.equal(request.url, "https://api.openai.com/v1/responses");
|
||||||
|
assert.equal(request.headers.get("authorization"), "Bearer dummy-oauth-native");
|
||||||
|
assert.equal(request.headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
|
||||||
|
const body = JSON.parse(await request.text()); assert.equal(body.model, model.id); assert.equal(body.store, false);
|
||||||
|
fetches++;
|
||||||
|
if (cut === "afterFetch") { cut = "none"; revoke(); throw new Error("admitted local abort"); }
|
||||||
|
if (toolFirst && fetches === 1) {
|
||||||
|
const call = { type: "function_call", id: "fc_again", call_id: "call_again", name: "again", namespace: "learnharness", arguments: "{}", status: "completed" };
|
||||||
|
return new Response([{ type: "response.output_item.added", output_index: 0, item: call }, { type: "response.output_item.done", output_index: 0, item: call }, { type: "response.completed", response: { status: "completed", output: [call], usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 } } }].map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
||||||
|
}
|
||||||
|
const item = { id: "msg_native", type: "message", role: "assistant", content: [{ type: "output_text", text: "Accepted learner", annotations: [] }] };
|
||||||
|
const events = [{ type: "response.created", response: { id: "resp_native" } }, { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, { type: "response.content_part.added", output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "Accepted learner" }, { type: "response.output_item.done", output_index: 0, item }, { type: "response.completed", response: { status: "completed", output: [item], usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 } } }];
|
||||||
|
return new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
fs.rmSync(scratch, { recursive: true, force: true });
|
||||||
|
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||||
|
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||||
|
});
|
||||||
|
const automatic = (id = "auto") => ({ id, role: "custom" as const, customType: "subagent-steer" as const, content: "Automatic result", details: { jobId: "dummy-job" }, display: true, timestamp: 2 });
|
||||||
|
async function nativeRun(where: typeof cut, staged: boolean, withLearner = true, commitThrows = false, lateAutomatic = false, withTool = false) {
|
||||||
|
epoch++; revision++; background = true; cut = where; leases = []; fetches = 0; toolFirst = withTool;
|
||||||
|
await refreshChatGPTModels([model]);
|
||||||
|
const seed = newUserMessage("Initial learner"); const marker = automatic(); const learner = { ...newUserMessage("New learner"), id: "learner-late" };
|
||||||
|
const messages = staged ? [seed, marker] : [seed];
|
||||||
|
const purpose = { background: staged };
|
||||||
|
let restored = 0, committed = 0, usage = 0, settled = 0, executed = 0, handedLate = false;
|
||||||
|
let final: AgentMessage[] = [];
|
||||||
|
let resolve!: () => void; const finished = new Promise<void>((done) => { resolve = done; });
|
||||||
|
const cycle = new ChatGPTRequestCycle({ initialMessages: messages, staged: staged ? [marker] : undefined, foregroundOwed: !staged, purpose, scopeCurrent: chatGPTScopeCurrent,
|
||||||
|
commitStaged: () => { if (commitThrows) throw new Error("scratch durable write failure"); committed++; },
|
||||||
|
restoreAutomatic: (ids, retained) => { restored++; assert(ids.has(marker.id)); assert(!retained.has(marker.id)); agent.clearSteeringQueue(); },
|
||||||
|
});
|
||||||
|
const raw = piEngineForSpec({ provider: "chatgpt", id: model.id }, () => purpose.background, cycle);
|
||||||
|
const engine = { ...raw, streamFn: (m: any, context: any, opts: any) => raw.streamFn(m, context, { ...opts, onPayload: async (payload: unknown) => { if (lateAutomatic && !handedLate) { handedLate = true; agent.steer(marker); } if (cut === "payload") { cut = "none"; await Promise.resolve(); revoke(); } return payload; } }) };
|
||||||
|
const agent = createTeacherAgent(undefined, { engine, messages, chatId: "scratch-native-session", tools: withTool ? { again: { name: "again", label: "again", description: "scratch", parameters: Type.Object({}), execute: async () => { executed++; return { content: [{ type: "text" as const, text: "Done" }], details: {} }; } } } : {}, callPurpose: purpose, requestCycle: cycle, onUsage: () => usage++ });
|
||||||
|
if (!staged && !lateAutomatic) agent.steer(marker);
|
||||||
|
if (withLearner) agent.steer(learner);
|
||||||
|
const handle = runPiTurn({ agent, requestCycle: cycle, onFinish: (m) => { settled++; final = m; resolve(); } });
|
||||||
|
const firstView = new Response(handle.subscribe()).text();
|
||||||
|
await finished;
|
||||||
|
const wire = await firstView; const replay = await new Response(handle.subscribe()).text();
|
||||||
|
assert.equal(settled, 1);
|
||||||
|
assert.equal(replay.includes('"id":"auto"'), wire.includes('"id":"auto"'));
|
||||||
|
assert.equal(replay.split('"type":"agent_end"').length, 2);
|
||||||
|
const events = wire.split("\n\n").filter((line) => line.startsWith("data: {")).map((line) => JSON.parse(line.slice(6)) as AgentEvent);
|
||||||
|
assert.equal(events.filter((e) => e.type === "agent_end").length, 1);
|
||||||
|
assert.equal(events.filter((e) => e.type === "agent_start").length, 1);
|
||||||
|
return { cycle, agent, final, wire, events, restored, committed, usage, executed, leases: [...leases], fetches };
|
||||||
|
}
|
||||||
|
for (const where of ["lease", "payload"] as const) for (const staged of [false, true]) {
|
||||||
|
test(`real native drain -> ${where} denial -> same Agent foreground recovery, staged=${staged}`, { timeout: 5000 }, async () => {
|
||||||
|
const r = await nativeRun(where, staged);
|
||||||
|
assert.equal(r.fetches, 1); assert.deepEqual(r.leases, [true, false]); assert.equal(r.usage, 1); assert.equal(r.restored, 1);
|
||||||
|
assert(!r.wire.includes('"id":"auto"')); assert(!r.wire.includes('"stopReason":"error"'));
|
||||||
|
assert.equal(r.final.filter((m) => (m as { id?: string }).id === "learner-late").length, 1);
|
||||||
|
assert.equal(r.events.filter((e) => e.type === "message_end" && (e.message as { id?: string }).id === "learner-late").length, 1);
|
||||||
|
assert.equal(r.events.filter((e) => e.type === "turn_start").length, 1);
|
||||||
|
assert.equal(await r.cycle.admission, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("initial automatic denial without learner creates no usage/visible marker/durable prefix", async () => {
|
||||||
|
const r = await nativeRun("lease", true, false); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.equal(r.committed, 0); assert.equal(await r.cycle.admission, false); assert(!r.wire.includes('"id":"auto"'));
|
||||||
|
});
|
||||||
|
test("full invalidation never internally retries under a new authority", async () => {
|
||||||
|
const r = await nativeRun("full", true); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.deepEqual(r.leases, [true]);
|
||||||
|
});
|
||||||
|
test("successful staged prefix commits once before native dispatch and is visible exactly once", async () => {
|
||||||
|
const r = await nativeRun("none", true, false); assert.equal(r.fetches, 1); assert.equal(r.committed, 1); assert.equal(await r.cycle.admission, true);
|
||||||
|
assert.equal(r.events.filter((e) => e.type === "message_end" && (e.message as { id?: string }).id === "auto").length, 1);
|
||||||
|
});
|
||||||
|
test("durable staged input failure prevents dispatch and does not infer foreground replay", async () => {
|
||||||
|
const r = await nativeRun("none", true, false, true); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.equal(await r.cycle.admission, false);
|
||||||
|
});
|
||||||
|
test("invalidation after fetch commitment cannot replay an admitted request", async () => {
|
||||||
|
const r = await nativeRun("afterFetch", false); assert.equal(r.fetches, 1); assert.equal(r.restored, 0); assert.deepEqual(r.leases, [true]); assert.equal(await r.cycle.admission, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automatic arrival after completed foreground prose cannot manufacture another foreground request", async () => {
|
||||||
|
const r = await nativeRun("lease", false, false, false, true);
|
||||||
|
assert.equal(r.fetches, 1); assert.equal(r.usage, 1); assert.equal(r.restored, 1); assert.deepEqual(r.leases, [false, true]);
|
||||||
|
assert(!r.wire.includes('"id":"auto"'));
|
||||||
|
});
|
||||||
|
test("unfinished foreground ordinary-tool chain recovers without rerunning the completed tool", async () => {
|
||||||
|
const r = await nativeRun("lease", false, false, false, true, true);
|
||||||
|
assert.equal(r.fetches, 2); assert.equal(r.executed, 1); assert.equal(r.usage, 2); assert.deepEqual(r.leases, [false, true, false]);
|
||||||
|
assert.equal(r.final.filter((m) => m.role === "toolResult").length, 1);
|
||||||
|
assert(!r.wire.includes('"id":"auto"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full native record reconstruction preserves original causes and reports genuine ambiguity", () => {
|
||||||
|
const user = newUserMessage("Learner"); const auto = automatic();
|
||||||
|
const assistant = { role: "assistant", id: "assistant", api: "openai-responses", provider: "chatgpt", model: model.id, content: [{ type: "toolCall", id: "call", name: "read", arguments: {} }], stopReason: "toolUse", timestamp: 3, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
|
||||||
|
const result = { role: "toolResult", id: "result", toolName: "read", toolCallId: "call", content: [], details: {}, isError: false, timestamp: 4 } as LhMessage;
|
||||||
|
for (const [origin, expected] of [[user, "foreground"], [auto, "background"]] as const) assert.equal(reconstructRequestCause(JSON.parse(JSON.stringify([origin, assistant, result]))), expected);
|
||||||
|
assert.equal(reconstructRequestCause([auto, user, assistant, result]), "ambiguous");
|
||||||
|
assert.equal(reconstructRequestCause([user, auto, assistant, result]), "background");
|
||||||
|
assert.equal(reconstructRequestCause([assistant, result]), "ambiguous");
|
||||||
|
assert.equal(reconstructRequestCause([auto, newThinkingMessage("problem", "working")]), "ambiguous");
|
||||||
|
assert.equal(reconstructRequestCause([auto, assistant, newNextMessage()]), "foreground");
|
||||||
|
assert.equal(reconstructRequestCause([user, ...concludedByDismissalPair("problem")]), "foreground");
|
||||||
|
const question = { ...assistant, content: [{ type: "toolCall", id: "question", name: "quiz", arguments: {} }] } as LhMessage;
|
||||||
|
assert.equal(reconstructRequestCause([auto, question, newAnswerResult("question", "quiz", { kind: "quiz", questionText: "Scratch", dontKnow: false, answers: [], answeredAt: "2026-10-24T00:00:00Z" })]), "foreground");
|
||||||
|
assert.equal(reconstructRequestCause([auto, question, { ...newAnswerResult("question", "quiz", {}), details: null }]), "background");
|
||||||
|
assert.equal(reconstructRequestCause([auto, question, newAnswerResult("orphan", "quiz", {})]), "ambiguous");
|
||||||
|
assert.equal(reconstructRequestCause([user, { ...user, content: "conflict" }]), "ambiguous");
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { normalizeContext, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { piModels, piEngineForSpec } from "../src/agent/models.ts";
|
||||||
|
import { chatGPTAbortController, chatGPTModels, refreshChatGPTModels } from "../src/agent/chatgpt-bridge.ts";
|
||||||
|
import { chatGPTHistory, chatGPTPayload } from "../src/agent/chatgpt-stream.ts";
|
||||||
|
|
||||||
|
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
||||||
|
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
const originalKey = process.env.OPENAI_API_KEY;
|
||||||
|
let leaseError: string | null = null;
|
||||||
|
let requests: { authorization: string | null; body: Record<string, any>; url: string }[] = [];
|
||||||
|
let responseEvents: unknown[] = [];
|
||||||
|
const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
|
||||||
|
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
||||||
|
const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null };
|
||||||
|
before(async () => {
|
||||||
|
assert(model);
|
||||||
|
process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
|
||||||
|
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||||
|
Object.defineProperty(process, "send", { configurable: true, value: (message: any, callback?: () => void) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ipc({ channel: "lh:chatgpt", id: message.id, op: message.op,
|
||||||
|
...(message.op === "lease" && leaseError ? { ok: false, error: leaseError, stamp: { epoch: 1, authorityRevision: 1, revision: 1 } } : { ok: true, value: message.op === "lease" ? { accessToken: "scratch-oauth-only", accountId: "scratch-account", epoch: 1, authorityRevision: 1, revision: 1 } : { epoch: 1, authorityRevision: 1, revision: 1, status, accountId: "scratch-account", models: [{ id: model.id, name: "Scratch account model" }, { id: "unknown-capabilities", name: "Do not invent capabilities" }] } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
callback?.(); return true;
|
||||||
|
} });
|
||||||
|
await refreshChatGPTModels([model]);
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const req = new Request(input, init);
|
||||||
|
requests.push({ authorization: req.headers.get("authorization"), body: JSON.parse(await req.text()), url: req.url });
|
||||||
|
return new Response(responseEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||||
|
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||||
|
if (originalKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = originalKey;
|
||||||
|
});
|
||||||
|
const usage = { input_tokens: 10, output_tokens: 4, total_tokens: 14, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } };
|
||||||
|
const complete = (output: unknown[] = []) => ({ type: "response.completed", response: { id: "resp_scratch", status: "completed", output, usage } });
|
||||||
|
const context = () => normalizeContext({ systemPrompt: "Scratch teacher instructions", messages: [{ role: "user", content: [{ type: "text", text: "Scratch learning material" }, { type: "image", data: "c2NyYXRjaA==", mimeType: "image/png" }], timestamp: 1 }], tools: [{ name: "scratch_tool", description: "Scratch local tool", parameters: Type.Object({ value: Type.String() }) }] });
|
||||||
|
|
||||||
|
test("catalog intersects supported capabilities, without inventing model limits", () => {
|
||||||
|
assert.equal(chatGPTModels().length, 1);
|
||||||
|
assert.equal(chatGPTModels()[0]!.provider, "chatgpt");
|
||||||
|
assert.equal(chatGPTModels()[0]!.contextWindow, model.contextWindow);
|
||||||
|
assert.deepEqual(chatGPTModels()[0]!.input, model.input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("alias resolves only OAuth; native Responses body is stateless/namespaced, image/history preserved and logical output retained", async () => {
|
||||||
|
requests = [];
|
||||||
|
const tool = { type: "function_call", id: "fc_scratch", call_id: "call_scratch", name: "scratch_tool", namespace: "learnharness", arguments: '{"value":"yes"}', status: "completed" };
|
||||||
|
const reasoning = { type: "reasoning", id: "rs_scratch", summary: [], encrypted_content: "scratch-encrypted-reasoning" };
|
||||||
|
responseEvents = [{ type: "response.created", response: { id: "resp_scratch" } }, { type: "response.output_item.added", output_index: 0, item: reasoning }, { type: "response.output_item.done", output_index: 0, item: reasoning }, { type: "response.output_item.added", output_index: 1, item: tool }, { type: "response.output_item.done", output_index: 1, item: tool }, complete([reasoning, tool])];
|
||||||
|
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||||
|
const stream = engine.streamFn(engine.model, context(), { temperature: 0.7, maxTokens: 1000, samplingParams: { top_p: 0.5 }, onPayload: (payload) => ({ ...payload as object, model: "caller-cannot-change", dangerous: true }) });
|
||||||
|
const events = [];
|
||||||
|
for await (const event of stream) { events.push(event); if ("partial" in event) assert.equal(event.partial.provider, "chatgpt"); }
|
||||||
|
const result = await stream.result();
|
||||||
|
assert.equal(result.provider, "chatgpt"); assert.equal(result.stopReason, "toolUse");
|
||||||
|
assert.equal(requests.length, 1); assert.equal(requests[0]!.url, "https://api.openai.com/v1/responses");
|
||||||
|
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||||
|
const body = requests[0]!.body;
|
||||||
|
assert.equal(body.store, false); assert.equal(body.stream, true);
|
||||||
|
for (const key of ["temperature", "top_p", "max_output_tokens", "previous_response_id", "background", "metadata", "prompt_cache_retention"]) assert.equal(body[key], undefined, key);
|
||||||
|
assert.equal(body.input[0].role, "developer");
|
||||||
|
assert.equal(body.input[1].content[1].type, "input_image");
|
||||||
|
assert.equal(body.tools[0].type, "namespace"); assert.equal(body.tools[0].tools[0].name, "scratch_tool");
|
||||||
|
const call = result.content.find((block) => block.type === "toolCall")!;
|
||||||
|
assert.equal(call.id, "call_scratch|fc_scratch"); assert.equal(call.namespace, "learnharness");
|
||||||
|
const thinking = result.content.find((block) => block.type === "thinking")!;
|
||||||
|
assert.equal(thinking.thinkingSignature, JSON.stringify(reasoning));
|
||||||
|
const history = normalizeContext({ messages: [...context().messages, result, { role: "toolResult", toolCallId: call.id, toolName: call.name, isError: false, content: [{ type: "text", text: "Scratch tool result" }], timestamp: 2 }] });
|
||||||
|
responseEvents = [complete()];
|
||||||
|
await engine.streamFn(engine.model, history).result();
|
||||||
|
const replay = requests[1]!.body.input;
|
||||||
|
assert(replay.some((item: any) => item.encrypted_content === "scratch-encrypted-reasoning"));
|
||||||
|
assert(replay.some((item: any) => item.type === "function_call" && item.id === "fc_scratch" && item.namespace === "learnharness"));
|
||||||
|
assert(replay.some((item: any) => item.type === "function_call_output" && item.call_id === "call_scratch"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mixed history never blanket-relabels API providers or reuses foreign reasoning signatures", () => {
|
||||||
|
const native: AssistantMessage = { role: "assistant", provider: "openai", api: "openai-responses", model: model.id, content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"API-only"}', redacted: true }], stopReason: "stop", usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, timestamp: 1 };
|
||||||
|
const own = { ...native, provider: "chatgpt", content: [{ type: "thinking" as const, thinking: "", thinkingSignature: '{"encrypted_content":"ChatGPT-only"}', redacted: true }] };
|
||||||
|
const transformed = chatGPTHistory(normalizeContext({ messages: [native, own] }), { ...model, provider: "chatgpt" });
|
||||||
|
assert.equal((transformed.messages[0] as AssistantMessage).provider, "openai");
|
||||||
|
assert.deepEqual((transformed.messages[0] as AssistantMessage).content, []);
|
||||||
|
assert.equal((transformed.messages[1] as AssistantMessage).content[0]!.type, "thinking");
|
||||||
|
assert.equal(native.content.length, 1, "source history must not be mutated");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no terminal completed is never success; late quota/incomplete streams are actionable/redacted", async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||||
|
for (const events of [[{ type: "response.created", response: { id: "resp_partial" } }], [{ type: "response.incomplete", response: { status: "incomplete", output: [], incomplete_details: { reason: "max_output_tokens" }, usage } }], [{ type: "error", code: "subscription_sharing_usage_limit_exceeded", message: "secret scratch provider diagnostic" }]]) {
|
||||||
|
responseEvents = events;
|
||||||
|
const result = await engine.streamFn(engine.model, context()).result();
|
||||||
|
assert.equal(result.stopReason, "error"); assert.equal(result.provider, "chatgpt");
|
||||||
|
assert(!result.errorMessage?.includes("secret scratch provider diagnostic"));
|
||||||
|
if (JSON.stringify(events).includes("usage_limit")) assert(result.errorMessage?.includes("https://chatgpt.com/settings/usage"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OAuth failure and background denial NEVER use ambient OPENAI_API_KEY", async () => {
|
||||||
|
requests = [];
|
||||||
|
for (const error of ["expired ChatGPT session", "Background use is off", "catalog unavailable", "quota exceeded"]) {
|
||||||
|
leaseError = error;
|
||||||
|
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, true);
|
||||||
|
assert.equal((await engine.streamFn(engine.model, context()).result()).stopReason, "error");
|
||||||
|
}
|
||||||
|
leaseError = null;
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consent revocation cancels only applicable background streams and preserves foreground", () => {
|
||||||
|
const foreground = chatGPTAbortController(false);
|
||||||
|
const background = chatGPTAbortController(true);
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 2 }, status, invalidate: "background" });
|
||||||
|
assert.equal(foreground.controller.signal.aborted, false);
|
||||||
|
assert.equal(background.controller.signal.aborted, true);
|
||||||
|
foreground.release(); background.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("payload sanitizer omits unsupported fields and rejects unsupported hosted tools", () => {
|
||||||
|
assert.deepEqual(chatGPTPayload({ model: "scratch", input: [], stream: false, store: true, metadata: { test: true }, previous_response_id: "old", background: true }, "scratch"), { model: "scratch", input: [], stream: true, store: false });
|
||||||
|
assert.throws(() => chatGPTPayload({ input: [], tools: [{ type: "tool_search" }] }, "scratch"), /Unsupported/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native identity/session/cache/fetch and awaited caller observers survive enforcement; terminal outcome exactly once", async () => {
|
||||||
|
const { APP_IDENTITY } = await import("../src/agent/models.ts");
|
||||||
|
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||||
|
let headers = new Headers(), payload: any, observed = 0, responses = 0, delegated = 0;
|
||||||
|
responseEvents = [complete()];
|
||||||
|
const stream = engine.streamFn(engine.model, context(), {
|
||||||
|
identity: APP_IDENTITY, sessionId: "exact-native-chat-session", timeoutMs: 12345,
|
||||||
|
headers: { "x-native-observer": "retained", "x-suppressed": null },
|
||||||
|
fetch: async (input, init) => { delegated++; const req = new Request(input, init); headers = req.headers; payload = JSON.parse(await req.text()); return new Response(`data: ${JSON.stringify(complete())}\n\n`, { headers: { "content-type": "text/event-stream" } }); },
|
||||||
|
onPayload: async (raw, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); return { ...raw as object, model: "not-the-selected-model", store: true, metadata: { forbidden: true } }; },
|
||||||
|
onResponse: async (_response, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); responses++; },
|
||||||
|
onProviderStreamEvent: async (_raw, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); observed++; },
|
||||||
|
});
|
||||||
|
const events = []; for await (const event of stream) events.push(event);
|
||||||
|
assert.equal(delegated, 1); assert.equal(observed, 1); assert.equal(responses, 1);
|
||||||
|
assert.equal(headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
|
||||||
|
assert.equal(headers.get("session_id"), "exact-native-chat-session");
|
||||||
|
assert.equal(headers.get("x-client-request-id"), "exact-native-chat-session");
|
||||||
|
assert.equal(headers.get("x-native-observer"), "retained"); assert.equal(headers.get("x-suppressed"), null);
|
||||||
|
assert.equal(payload.prompt_cache_key, "exact-native-chat-session"); assert.equal(payload.model, model.id); assert.equal(payload.metadata, undefined);
|
||||||
|
assert.equal(events.filter((e) => e.type === "done" || e.type === "error").length, 1);
|
||||||
|
const failed = engine.streamFn(engine.model, context(), { onProviderStreamEvent: async () => { throw new Error("private observer detail"); } });
|
||||||
|
const failedEvents = []; for await (const event of failed) failedEvents.push(event);
|
||||||
|
assert.equal(failedEvents.filter((e) => e.type === "error").length, 1); assert.equal(failedEvents.filter((e) => e.type === "done").length, 0);
|
||||||
|
assert(!(await failed.result()).errorMessage?.includes("private observer detail"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("case-insensitive Authorization overrides including null are refused before native fetch", async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||||
|
const before = requests.length;
|
||||||
|
for (const headers of [{ Authorization: "Bearer wrong" }, { authorization: null }, { AUTHORIZATION: "" }] as Record<string, string | null>[]) {
|
||||||
|
assert.equal((await engine.streamFn(engine.model, context(), { headers }).result()).stopReason, "error");
|
||||||
|
}
|
||||||
|
assert.equal(requests.length, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full secure-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||||
|
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||||
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||||
|
assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
|
||||||
|
foreground.release(); background.release();
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test, afterEach } from "node:test";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { useModelCatalog, catalogLimits } from "../components/model-picker.tsx";
|
||||||
|
import { useZeroKeys, useStrongKeyless } from "../components/setup-cta.tsx";
|
||||||
|
import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||||
|
import { SidebarProvider } from "../components/sidebar.tsx";
|
||||||
|
import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
|
||||||
|
import { GraphCostControl } from "../components/graph-view.tsx";
|
||||||
|
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||||
|
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
|
||||||
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
|
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||||
|
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||||
|
function body(id: string, ready = true): ModelCatalogResponse {
|
||||||
|
return { models: ready ? [{ id, name: id, provider: "chatgpt", input: ["text"], contextWindow: 8192, maxTokens: 1024, reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [], providers: [{ id: "chatgpt", envVar: "", keyPresent: ready }], slots: { strong: { model: { provider: "chatgpt", id: "explicit-pin" } }, cheap: null, vision: null }, defaults: {} } as ModelCatalogResponse;
|
||||||
|
}
|
||||||
|
let snapshots: Record<string, { catalog?: ModelCatalogResponse; zero: boolean; strong: boolean }> = {};
|
||||||
|
function Reader({ id }: { id: string }) {
|
||||||
|
const { catalog } = useModelCatalog();
|
||||||
|
snapshots[id] = { catalog, zero: useZeroKeys(), strong: useStrongKeyless() };
|
||||||
|
return <span data-testid={id}>{catalog ? [...catalogLimits(catalog).keys()].join(",") : "unavailable"}</span>;
|
||||||
|
}
|
||||||
|
async function mount(children: React.ReactNode) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); await act(async () => root!.render(children)); }
|
||||||
|
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; delete window.lh; snapshots = {}; });
|
||||||
|
|
||||||
|
test("shared ChatGPT-only readiness admits without API keys; connection invalidation clears every reader and fences old success/error", async () => {
|
||||||
|
const requests: ReturnType<typeof deferred<Response>>[] = [];
|
||||||
|
globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); const request = deferred<Response>(); requests.push(request); return request.promise; };
|
||||||
|
await mount(<><Reader id="first" /><Reader id="second" /></>);
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
await act(async () => requests[0]!.resolve(Response.json(body("A"))));
|
||||||
|
for (const value of Object.values(snapshots)) { assert.equal(value.zero, false); assert.equal(value.strong, false); assert.equal(value.catalog?.models[0]?.id, "A"); }
|
||||||
|
await act(async () => notifySettingsSaved("models"));
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
await act(async () => notifySettingsSaved("keys"));
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
for (const value of Object.values(snapshots)) assert.equal(value.catalog, undefined);
|
||||||
|
await act(async () => requests[2]!.resolve(Response.json(body("B", false))));
|
||||||
|
await act(async () => requests[1]!.resolve(Response.json(body("stale-A"))));
|
||||||
|
for (const value of Object.values(snapshots)) { assert.equal(value.catalog?.models.length, 0); assert.equal(value.zero, true); assert.equal(value.strong, true); }
|
||||||
|
assert(!document.body.textContent?.includes("chatgpt:A"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan plus unknown graph rows keep the existing unknown suffix without fabricating API dollars", async () => {
|
||||||
|
const step = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 };
|
||||||
|
const plan = { model: "chatgpt:gpt-5", steps: [step] };
|
||||||
|
const unknown = { model: "unknown:missing", steps: [step] };
|
||||||
|
const costs = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ startedAt: 1, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: plan, auditor: unknown } }, { startedAt: 2, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: unknown, auditor: plan } }] }], sources: [] });
|
||||||
|
await mount(<GraphCostControl costs={costs} />);
|
||||||
|
await act(async () => { document.querySelector("button")!.click(); });
|
||||||
|
const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
|
||||||
|
assert.equal(recent.length, 2);
|
||||||
|
for (const row of recent) assert.equal(row.textContent, "ChatGPT plan +1 unpriced", "both recent two-stage runs disclose their unknown stage");
|
||||||
|
assert.equal((document.body.textContent?.match(/\+1 unpriced/g) ?? []).length, 4, "both mixed stage rows also retain their unknown suffix");
|
||||||
|
assert(!document.body.textContent?.includes("$0.00"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("root-lifetime safe bridge publishes with Connections unmounted; commands/panel pushes do not duplicate global signals", async () => {
|
||||||
|
let pushes: (() => void)[] = [], subscriptions = 0, unsubscriptions = 0, fetches = 0;
|
||||||
|
const safe: ChatGPTStatus = { available: true, pending: false, allowBackgroundUse: false, activeId: "a", accounts: [{ id: "a", email: "dummy@example.invalid", registration: "oaiapp_dummy", connected: true, planEnabled: true }], error: null };
|
||||||
|
window.lh = { expandWindow() {}, chatgpt: { status: async () => safe, login: async () => safe, cancel: async () => safe, select: async () => safe, disconnect: async () => safe, logout: async () => safe, consent: async () => safe, onChange: (cb) => { subscriptions++; pushes.push(cb); return () => { unsubscriptions++; pushes = pushes.filter((c) => c !== cb); }; } } };
|
||||||
|
globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); fetches++; return Response.json(body(`model-${fetches}`)); };
|
||||||
|
await mount(<SidebarProvider><Reader id="root" /></SidebarProvider>);
|
||||||
|
assert.equal(subscriptions, 1);
|
||||||
|
const before = fetches;
|
||||||
|
await act(async () => { for (const callback of pushes) callback(); });
|
||||||
|
assert.equal(fetches, before + 1);
|
||||||
|
await act(async () => root!.render(<SidebarProvider><Reader id="root" /><ChatGPTUIProvider><ChatGPTConnection /></ChatGPTUIProvider></SidebarProvider>));
|
||||||
|
assert.equal(subscriptions, 2);
|
||||||
|
const mounted = fetches;
|
||||||
|
await act(async () => { for (const callback of pushes) callback(); });
|
||||||
|
assert.equal(fetches, mounted + 1, "one global publisher even when panel also reads pushed status");
|
||||||
|
await act(async () => root!.render(<SidebarProvider><Reader id="root" /></SidebarProvider>));
|
||||||
|
assert.equal(unsubscriptions, 1);
|
||||||
|
await act(async () => { for (const callback of pushes) callback(); });
|
||||||
|
assert.equal(fetches, mounted + 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts";
|
||||||
|
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||||
|
import type { TurnUsage } from "../src/core/contracts.ts";
|
||||||
|
import { planCostLabel } from "../components/usage-format.ts";
|
||||||
|
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||||
|
const turn = (model: string, steps = [step]): TurnUsage => ({ id: "scratch-turn", messageId: "scratch-message", at: "2026-10-19T00:00:00Z", model, steps });
|
||||||
|
|
||||||
|
test("subscription usage is neither API dollars nor an unpriced-model mistake, including empty/partial usage", () => {
|
||||||
|
const plan = turn("chatgpt:gpt-5");
|
||||||
|
assert.equal(callCost(step, plan.model), null);
|
||||||
|
assert.equal(turnCost(turn(plan.model, [])), null);
|
||||||
|
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
|
||||||
|
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2 });
|
||||||
|
const api = turn("openai:gpt-5");
|
||||||
|
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
|
||||||
|
assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
|
||||||
|
assert.equal(planCostLabel(null, true), "ChatGPT plan");
|
||||||
|
assert.match(planCostLabel(mixed.cost, true), /API \+ ChatGPT plan$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
|
||||||
|
const base = { startedAt: 1, at: "2026-10-19T00:00:00Z", durationMs: 1, stages: { extractor: { model: "chatgpt:gpt-5", steps: [step] }, auditor: { model: "chatgpt:gpt-5", steps: [] } } };
|
||||||
|
const plan = graphCostSummary({ chats: [{ title: "Scratch", extractions: [base] }], sources: [] });
|
||||||
|
assert.equal(plan.unpricedRuns, 0); assert.equal(plan.subscriptionRuns, 2);
|
||||||
|
assert.equal(plan.stages.extraction.unpricedRuns, 0); assert.equal(plan.stages.audit.unpricedRuns, 0);
|
||||||
|
assert.equal(plan.runs[0]!.dollars, null);
|
||||||
|
const mixed = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "openai:gpt-5", steps: [step] } } }] }], sources: [] });
|
||||||
|
assert.equal(mixed.subscriptionRuns, 1);
|
||||||
|
assert.equal(mixed.totalDollars, callCost(step, "openai:gpt-5"));
|
||||||
|
assert.equal(mixed.runs[0]!.subscriptionRuns, 1);
|
||||||
|
assert.equal(mixed.unpricedRuns, 0);
|
||||||
|
for (const reverse of [false, true]) {
|
||||||
|
const stages = [{ model: "chatgpt:gpt-5", steps: [step] }, { model: "unknown:not-priced", steps: [step] }];
|
||||||
|
if (reverse) stages.reverse();
|
||||||
|
const unknown = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { extractor: stages[0]!, auditor: stages[1]! } }] }], sources: [] });
|
||||||
|
assert.equal(unknown.unpricedRuns, 1); assert.equal(unknown.runs[0]!.dollars, null); assert.equal(unknown.subscriptionRuns, 1);
|
||||||
|
assert.equal(unknown.stages.extraction.unpricedRuns + unknown.stages.audit.unpricedRuns, 1);
|
||||||
|
}
|
||||||
|
const zero = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "openai:gpt-5", steps: [{ ...step, inputTokens: 0, outputTokens: 0 }] } } }] }], sources: [] });
|
||||||
|
assert.equal(zero.runs[0]!.dollars, 0); assert.equal(zero.unpricedRuns, 0); assert.equal(zero.subscriptionRuns, 1);
|
||||||
|
const capped = graphCostSummary({ recentCap: 0, chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "unknown:model", steps: [step] } } }] }], sources: [] });
|
||||||
|
assert.equal(capped.runs.length, 0); assert.equal(capped.unpricedRuns, 1);
|
||||||
|
});
|
||||||
@@ -74,6 +74,23 @@ const depsOf = (chatId: string, thresholdTokens: number): CompactionDeps => ({
|
|||||||
// ── the laws ────────────────────────────────────────────────────────────────
|
// ── the laws ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("the compaction service", () => {
|
describe("the compaction service", () => {
|
||||||
|
it("background eligibility skips both entrypoints without writes, but still awaits an existing structural run", async () => {
|
||||||
|
const chatId = overThresholdChat("compact: consent off");
|
||||||
|
const before = summarizeCalls;
|
||||||
|
const denied = { ...depsOf(chatId, 40_000), canStart: () => false };
|
||||||
|
await ensureCompacted(denied, chatId); kickCompaction(() => denied, chatId);
|
||||||
|
assert.equal(summarizeCalls, before);
|
||||||
|
assert.equal(activeCompactionOf(chatStore.getChat(chatId)!.messages), null);
|
||||||
|
let release!: () => void, entered!: () => void;
|
||||||
|
const gate = new Promise<void>((done) => { release = done; });
|
||||||
|
const started = new Promise<void>((done) => { entered = done; });
|
||||||
|
const allowed = { ...depsOf(chatId, 40_000), summarize: async (prompt: string) => { entered(); await gate; return depsOf(chatId, 40_000).summarize!(prompt, denied.engine); } };
|
||||||
|
kickCompaction(() => allowed, chatId); await started;
|
||||||
|
let finished = false;
|
||||||
|
const waiter = ensureCompacted(denied, chatId).then(() => { finished = true; });
|
||||||
|
await Promise.resolve(); assert.equal(finished, false);
|
||||||
|
release(); await waiter; assert.equal(finished, true);
|
||||||
|
});
|
||||||
it("over-threshold record → marker appended, usage logged, both events emitted", async () => {
|
it("over-threshold record → marker appended, usage logged, both events emitted", async () => {
|
||||||
const chatId = overThresholdChat("compact: basic");
|
const chatId = overThresholdChat("compact: basic");
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
// Installed Next 16.3.2 retains the old test-helper export despite proxy.md's new name.
|
||||||
|
import { unstable_doesMiddlewareMatch as unstable_doesProxyMatch } from "next/experimental/testing/server";
|
||||||
|
import { desktopAPIAuthorized } from "../src/server/desktop-access.ts";
|
||||||
|
import { proxy, config } from "../proxy.ts";
|
||||||
|
import { browserServiceSpawnEnv } from "../src/agent/subagents/headless.ts";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { desktopAPIHeaders, HEADER } = require("../electron/desktop-api.cjs");
|
||||||
|
const { trustedSender, chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
|
||||||
|
const secret = "a".repeat(64);
|
||||||
|
const origin = "http://127.0.0.1:12345";
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const mainFrame = { url: `${origin}/chat/new` };
|
||||||
|
const contents = { id: 5, mainFrame, isDestroyed: () => false };
|
||||||
|
const win = { webContents: contents, isDestroyed: () => false };
|
||||||
|
const details = { url: `${origin}/api/settings`, webContentsId: contents.id, webContents: contents, frame: mainFrame, initiatorOrigin: origin, requestHeaders: { Accept: "application/json" } };
|
||||||
|
return { mainFrame, contents, win, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("desktop capability injects only trusted app mainframe exact-origin API requests and strips every other case", () => {
|
||||||
|
const { details, win, mainFrame, contents } = fixture();
|
||||||
|
assert.equal(desktopAPIHeaders(details, origin, secret, [win])[HEADER], secret);
|
||||||
|
for (const mutation of [
|
||||||
|
{ url: "https://thirdparty.invalid/api/settings" },
|
||||||
|
{ url: "http://127.0.0.1:12346/api/settings" },
|
||||||
|
{ url: `${origin}/chat/new` },
|
||||||
|
{ frame: { url: `${origin}/chat/new` } }, // forged frame identity
|
||||||
|
{ frame: { url: "about:srcdoc", parent: mainFrame } },
|
||||||
|
{ frame: null },
|
||||||
|
{ webContentsId: 6 },
|
||||||
|
{ webContents: { ...contents } },
|
||||||
|
{ initiatorOrigin: "https://thirdparty.invalid" },
|
||||||
|
{ initiatorOrigin: "null" },
|
||||||
|
]) {
|
||||||
|
const result = desktopAPIHeaders({ ...details, ...mutation, requestHeaders: { "X-LearnHarness-Desktop": secret } }, origin, secret, [win]);
|
||||||
|
assert(!Object.keys(result).some((key) => key.toLowerCase() === HEADER), JSON.stringify(mutation));
|
||||||
|
}
|
||||||
|
assert.equal(desktopAPIHeaders(details, origin, secret, [])[HEADER], undefined, "other WebContents are not app windows");
|
||||||
|
mainFrame.url = "https://thirdparty.invalid/after-navigation";
|
||||||
|
assert.equal(desktopAPIHeaders(details, origin, secret, [win])[HEADER], undefined, "navigation revokes trust even for the same native frame");
|
||||||
|
assert.equal(desktopAPIHeaders(details, origin, null, [win])[HEADER], undefined, "externally launched desktop dev receives no packaged capability");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all methods fail closed for desktop missing/wrong secret; API-only matcher covers SSE/settings/spending", () => {
|
||||||
|
assert(desktopAPIAuthorized(new Headers(), {}), "browser-only API-key mode unchanged");
|
||||||
|
assert(!desktopAPIAuthorized(new Headers({ [HEADER]: secret }), { LH_DESKTOP_SERVER: "1" }), "missing server secret must not disable authorization");
|
||||||
|
for (const value of [undefined, "", "wrong", secret + ", " + secret]) {
|
||||||
|
assert(!desktopAPIAuthorized(new Headers(value === undefined ? {} : { [HEADER]: value }), { LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret }));
|
||||||
|
}
|
||||||
|
assert(desktopAPIAuthorized(new Headers({ [HEADER]: secret }), { LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret }));
|
||||||
|
for (const pathname of ["/api/events", "/api/chat", "/api/settings/models", "/api/chats/scratch/markdown"]) assert(unstable_doesProxyMatch({ config, nextConfig: {}, url: origin + pathname }));
|
||||||
|
assert(!unstable_doesProxyMatch({ config, nextConfig: {}, url: origin + "/chat/new" }));
|
||||||
|
const originalMarker = process.env.LH_DESKTOP_SERVER;
|
||||||
|
const originalSecret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
|
try {
|
||||||
|
process.env.LH_DESKTOP_SERVER = "1";
|
||||||
|
process.env.LH_DESKTOP_API_SECRET = secret;
|
||||||
|
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) {
|
||||||
|
assert.equal(proxy(new NextRequest(origin + "/api/settings", { method })).status, 403);
|
||||||
|
assert.equal(proxy(new NextRequest(origin + "/api/settings", { method, headers: { [HEADER]: secret } })).headers.get("x-middleware-next"), "1");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = originalMarker;
|
||||||
|
if (originalSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = originalSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderer controls route explicit account forgetting and account-independent boolean consent only", async () => {
|
||||||
|
const forgotten: string[] = [], changes: boolean[] = [];
|
||||||
|
const connection = { disconnect: async (id: string) => { forgotten.push(id); return { ok: true }; }, consent: async (enabled: boolean) => { changes.push(enabled); return { ok: true }; } };
|
||||||
|
assert.deepEqual(await chatGPTControl({ op: "disconnect", id: "fixture-account" }, connection), { ok: true });
|
||||||
|
assert.deepEqual(await chatGPTControl({ op: "consent", enabled: false }, connection), { ok: true });
|
||||||
|
assert.deepEqual(forgotten, ["fixture-account"]); assert.deepEqual(changes, [false]);
|
||||||
|
for (const command of [{ op: "disconnect" }, { op: "disconnect", id: 7 }, { op: "consent", enabled: "false" }]) assert.deepEqual(await chatGPTControl(command, connection), { error: "Unsupported ChatGPT control." });
|
||||||
|
assert.deepEqual(changes, [false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderer controls have no lease or general token bridge, and require the trusted main frame", async () => {
|
||||||
|
let leased = false;
|
||||||
|
const connection = { lease: async () => { leased = true; return { accessToken: "scratch-private-token" }; } };
|
||||||
|
assert.deepEqual(await chatGPTControl({ op: "lease", background: false }, connection), { error: "Unsupported ChatGPT control." });
|
||||||
|
assert.equal(leased, false);
|
||||||
|
const { contents, mainFrame, win } = fixture();
|
||||||
|
const BrowserWindow = { fromWebContents: (value: unknown) => value === contents ? win : null };
|
||||||
|
assert(trustedSender({ sender: contents, senderFrame: mainFrame }, origin, BrowserWindow));
|
||||||
|
assert(!trustedSender({ sender: contents, senderFrame: { url: mainFrame.url } }, origin, BrowserWindow));
|
||||||
|
mainFrame.url = "https://thirdparty.invalid/";
|
||||||
|
assert(!trustedSender({ sender: contents, senderFrame: mainFrame }, origin, BrowserWindow));
|
||||||
|
assert.equal(browserServiceSpawnEnv({ LH_DESKTOP_API_SECRET: secret, LH_DESKTOP_SERVER: "1" }).LH_DESKTOP_API_SECRET, undefined);
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { describe, it } from "node:test";
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
|
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
|
||||||
const mainSource = readFileSync(mainFile, "utf8");
|
const mainSource = readFileSync(mainFile, "utf8");
|
||||||
|
const mainRequire = createRequire(mainFile);
|
||||||
|
|
||||||
/** Decline the single-instance lock so normal boot never spawns a server.
|
/** Decline the single-instance lock so normal boot never spawns a server.
|
||||||
* Then call the REAL createWindow with a stub Electron API and inspect its
|
* Then call the REAL createWindow with a stub Electron API and inspect its
|
||||||
@@ -27,7 +28,7 @@ function windowOptions(platform: string) {
|
|||||||
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
||||||
};
|
};
|
||||||
const context = {
|
const context = {
|
||||||
require: (id: string) => id === "electron" ? electron : require(id),
|
require: (id: string) => id === "electron" ? electron : mainRequire(id),
|
||||||
process: { platform, argv: [], env: {}, on() {} },
|
process: { platform, argv: [], env: {}, on() {} },
|
||||||
__dirname: dirname(mainFile),
|
__dirname: dirname(mainFile),
|
||||||
};
|
};
|
||||||
@@ -77,7 +78,7 @@ describe("desktop window chrome", () => {
|
|||||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
||||||
assert.equal(bridge.platform, "win32");
|
assert.equal(bridge.platform, "win32");
|
||||||
assert.deepEqual(Object.keys(bridge).sort(), [
|
assert.deepEqual(Object.keys(bridge).sort(), [
|
||||||
"expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
|
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+224
-20
@@ -22,9 +22,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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, type Usage } from "@earendil-works/pi-ai";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
||||||
@@ -44,6 +45,7 @@ import type { GraphStore } from "../src/core/ports.ts";
|
|||||||
import {
|
import {
|
||||||
abortExtraction,
|
abortExtraction,
|
||||||
createExtractionPipeline,
|
createExtractionPipeline,
|
||||||
|
NotesImportError,
|
||||||
extractionStatuses,
|
extractionStatuses,
|
||||||
transcriptView,
|
transcriptView,
|
||||||
sourceDigest,
|
sourceDigest,
|
||||||
@@ -871,21 +873,30 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const verdict: AuditVerdicts = {
|
const verdict: AuditVerdicts = {
|
||||||
links: [{ id: FINDINGS.propositions[0]!.id, linkTo: existingId }],
|
links: [{ id: FINDINGS.propositions[0]!.id, linkTo: existingId }],
|
||||||
};
|
};
|
||||||
|
const nativeUsage: Record<"extractor" | "auditor", Usage[]> = { extractor: [], auditor: [] };
|
||||||
|
const recordUsage = (stage: "extractor" | "auditor", engine: PiEngine): PiEngine => ({
|
||||||
|
...engine,
|
||||||
|
streamFn: (model, context, options) => {
|
||||||
|
const stream = engine.streamFn(model, context, options);
|
||||||
|
void stream.result().then((message) => nativeUsage[stage].push(message.usage));
|
||||||
|
return stream;
|
||||||
|
},
|
||||||
|
});
|
||||||
const pipeline = createExtractionPipeline({
|
const pipeline = createExtractionPipeline({
|
||||||
chatStore,
|
chatStore,
|
||||||
graphStore: ledgers,
|
graphStore: ledgers,
|
||||||
// During the auditor's generate, the registry must already carry
|
// During the auditor's generate, the registry must already carry
|
||||||
// the extractor's fully accumulated live usage (2 steps × 10/5).
|
// the extractor's fully accumulated live usage (2 steps × 10/5).
|
||||||
extractor: submitModel("submit_findings", FINDINGS),
|
extractor: recordUsage("extractor", submitModel("submit_findings", FINDINGS)),
|
||||||
auditor: submitModel("submit_review", verdict, () =>
|
auditor: recordUsage("auditor", submitModel("submit_review", verdict, () =>
|
||||||
usageSeen.push(extractionStatuses().find((r) => r.chatId === chatId)?.usage.extractor?.steps.at(-1)?.inputTokens),
|
usageSeen.push(extractionStatuses().find((r) => r.chatId === chatId)?.usage.extractor?.steps.at(-1)?.inputTokens),
|
||||||
),
|
)),
|
||||||
});
|
});
|
||||||
await pipeline.runExtraction(chatId);
|
await pipeline.runExtraction(chatId);
|
||||||
|
|
||||||
// Live: mid-audit the extractor's usage was fully accumulated (faux
|
// Live: mid-audit the extractor's usage was fully accumulated (faux
|
||||||
// estimates tokens from the prompt/response text — deterministic,
|
// estimates tokens from the prompt/response text — deterministic,
|
||||||
// positive, no cache).
|
// positive, with native first-conversation cache writes in 1.4).
|
||||||
assert.equal(usageSeen.length, 1);
|
assert.equal(usageSeen.length, 1);
|
||||||
assert.ok(typeof usageSeen[0] === "number" && usageSeen[0]! > 0, "the extractor's step billed input tokens");
|
assert.ok(typeof usageSeen[0] === "number" && usageSeen[0]! > 0, "the extractor's step billed input tokens");
|
||||||
|
|
||||||
@@ -901,8 +912,16 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const step = run.stages.extractor.steps[0]!;
|
const step = run.stages.extractor.steps[0]!;
|
||||||
assert.ok(step.inputTokens! > 0 && step.outputTokens! > 0, "estimated usage captured");
|
assert.ok(step.inputTokens! > 0 && step.outputTokens! > 0, "estimated usage captured");
|
||||||
assert.equal(step.cacheReadTokens, 0);
|
assert.equal(step.cacheReadTokens, 0);
|
||||||
assert.equal(step.cacheWriteTokens, 0);
|
assert.equal(nativeUsage.extractor.length, 1);
|
||||||
|
assert.ok(nativeUsage.extractor[0]!.cacheWrite > 0, "fresh native conversation writes its cached prefix");
|
||||||
|
assert.equal(step.cacheReadTokens, nativeUsage.extractor[0]!.cacheRead);
|
||||||
|
assert.equal(step.cacheWriteTokens, nativeUsage.extractor[0]!.cacheWrite);
|
||||||
assert.equal(step.reasoningTokens, 0);
|
assert.equal(step.reasoningTokens, 0);
|
||||||
|
assert.equal(nativeUsage.auditor.length, 1);
|
||||||
|
assert.equal(run.stages.auditor.steps[0]!.cacheReadTokens, 0, "auditor owns a fresh conversation too");
|
||||||
|
assert.ok(nativeUsage.auditor[0]!.cacheWrite > 0);
|
||||||
|
assert.equal(run.stages.auditor.steps[0]!.cacheReadTokens, nativeUsage.auditor[0]!.cacheRead);
|
||||||
|
assert.equal(run.stages.auditor.steps[0]!.cacheWriteTokens, nativeUsage.auditor[0]!.cacheWrite);
|
||||||
assert.equal(run.stages.auditor.steps.length, 1);
|
assert.equal(run.stages.auditor.steps.length, 1);
|
||||||
assert.ok(!Number.isNaN(Date.parse(run.at)));
|
assert.ok(!Number.isNaN(Date.parse(run.at)));
|
||||||
});
|
});
|
||||||
@@ -1584,7 +1603,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
|||||||
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable");
|
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 +1615,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 +1644,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 +1952,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 +2335,190 @@ describe("chain simplification (the merger's maintenance pass, owner 2026-09-30
|
|||||||
assert.equal(stored.length, 2, "the ledger is back to the two lean chains");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -610,6 +610,43 @@ describe("research runner (async protocol §3)", () => {
|
|||||||
assert.deepEqual(search.queries, ["sigma.js latest version (5)"]);
|
assert.deepEqual(search.queries, ["sigma.js latest version (5)"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a keyless fetch-only researcher still completes and delivers a sourced job result", async () => {
|
||||||
|
const url = "https://example.com/supplied-source";
|
||||||
|
const brief = `## Summary\nVerified supplied source. [Source](${url})\n\n## Sources\n- Source (${url})`;
|
||||||
|
const input = { question: `Verify ${url}` };
|
||||||
|
const chat = chatStore.createChat("Test: keyless fetch-only research");
|
||||||
|
trackChat(chat.id);
|
||||||
|
chatStore.saveMessages(chat.id, nativeToolMessages("verify supplied URL", "research", "call-keyless", input));
|
||||||
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-keyless" });
|
||||||
|
trackJob(job.id);
|
||||||
|
const delivery = fakeDeliver();
|
||||||
|
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver, searchProvider: () => null });
|
||||||
|
const fetched: string[] = [];
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (source) => {
|
||||||
|
fetched.push(String(source));
|
||||||
|
assert.equal(String(source), url);
|
||||||
|
return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
researchModelOverride.current = fauxEngine([
|
||||||
|
fauxAssistantMessage([fauxToolCall("web_fetch", { url }, { id: "keyless-fetch" })], { stopReason: "toolUse" }),
|
||||||
|
fauxAssistantMessage([fauxText(brief)]),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await runner.runJob(job.id);
|
||||||
|
} finally {
|
||||||
|
researchModelOverride.current = undefined;
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
}
|
||||||
|
assert.deepEqual(fetched, [url]);
|
||||||
|
const done = jobStore.getJob(job.id)!;
|
||||||
|
assert.equal(done.status, "done");
|
||||||
|
assert.equal((done.result as ResearchResult).brief, brief);
|
||||||
|
assert.ok((done.result as ResearchResult).sources.some((s) => s.url === url));
|
||||||
|
assert.equal(delivery.steers.length, 1);
|
||||||
|
assert.equal(isSubagentFailure("research", done.result as ResearchResult), false);
|
||||||
|
});
|
||||||
|
|
||||||
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
|
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
|
||||||
const chat = chatStore.createChat("Test: runner failure");
|
const chat = chatStore.createChat("Test: runner failure");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/** GHSA-238p-pmpm-9mq7: inherited properties must not become KaTeX authority.
|
||||||
|
* Output is inspected as inert strings/LinkeDOM; no resource or script is run.
|
||||||
|
* node:test runs files in separate processes. Within this file all prototype
|
||||||
|
* mutations are synchronous, non-concurrent, and restored even on failure.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import katex from "katex";
|
||||||
|
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
const blocked = String.raw`\href{https://katex-security.invalid/blocked}{blocked}`;
|
||||||
|
function withoutAuthority(html: string) {
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
assert.equal(document.querySelectorAll("[href], [src], a, img, script, iframe").length, 0, "inherited authority must not emit active HTML or URL attributes");
|
||||||
|
assert.ok(html.includes("blocked"), "the formula was actually processed");
|
||||||
|
}
|
||||||
|
function withPrototype(key: string, value: unknown, run: () => void) {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(Object.prototype, key);
|
||||||
|
try {
|
||||||
|
Object.defineProperty(Object.prototype, key, { value, configurable: true, writable: true });
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
if (previous) Object.defineProperty(Object.prototype, key, previous);
|
||||||
|
else Reflect.deleteProperty(Object.prototype, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function markdown() {
|
||||||
|
return renderToStaticMarkup(React.createElement(MessageResponse, { mode: "static" }, `$${blocked}$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("KaTeX prototype read-side security", { concurrency: false }, () => {
|
||||||
|
it("ignores Object.prototype.trust in direct and app markdown rendering", () => {
|
||||||
|
withPrototype("trust", true, () => {
|
||||||
|
withoutAuthority(katex.renderToString(blocked, { strict: false }));
|
||||||
|
withoutAuthority(markdown());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("ignores trust inherited from a custom options prototype", () => {
|
||||||
|
const options = Object.assign(Object.create({ trust: true }), { strict: false });
|
||||||
|
withoutAuthority(katex.renderToString(blocked, options));
|
||||||
|
});
|
||||||
|
it("ignores inherited schema defaults in direct and app markdown rendering", () => {
|
||||||
|
withPrototype("default", true, () => {
|
||||||
|
withoutAuthority(katex.renderToString(blocked, { strict: false }));
|
||||||
|
withoutAuthority(markdown());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("ignores inherited schema processors, including for own trust:false", () => {
|
||||||
|
withPrototype("processor", (value: unknown) => value === false ? true : value, () => {
|
||||||
|
withoutAuthority(katex.renderToString(blocked, { strict: false, trust: false }));
|
||||||
|
withoutAuthority(markdown());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps own trust:false and own restricted callbacks restrictive", () => {
|
||||||
|
withPrototype("trust", true, () => {
|
||||||
|
withoutAuthority(katex.renderToString(blocked, { strict: false, trust: false }));
|
||||||
|
const commands: string[] = [];
|
||||||
|
withoutAuthority(katex.renderToString(blocked, {
|
||||||
|
strict: false,
|
||||||
|
trust: (context) => { commands.push(context.command); return context.command === "\\url"; },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(commands, ["\\href"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("ignores inherited builtin namespace entries", () => {
|
||||||
|
withPrototype("\\securityInherited", "INHERITED", () => {
|
||||||
|
// The parser may reject it as an unknown function rather than an
|
||||||
|
// undefined command; either way the inherited macro must not expand.
|
||||||
|
assert.throws(() => katex.renderToString(String.raw`\securityInherited`), /KaTeX parse error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("does not restore an inherited macro as an own definition after a group", () => {
|
||||||
|
const macros = Object.create({ "\\securityRestore": "INHERITED" });
|
||||||
|
const html = katex.renderToString(String.raw`{\def\securityRestore{L}\securityRestore}`, { macros });
|
||||||
|
assert.ok(parseHTML(html).document.querySelector(".katex-html")?.textContent?.includes("L"));
|
||||||
|
assert.equal(Object.hasOwn(macros, "\\securityRestore"), false, "group restoration must delete, not promote an inherited value");
|
||||||
|
assert.throws(() => katex.renderToString(String.raw`\securityRestore`, { macros }), /Undefined control sequence/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
|
||||||
|
import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts";
|
||||||
|
import { callCost } from "../src/core/prices.ts";
|
||||||
|
|
||||||
|
const specimens = [
|
||||||
|
{ id: "recursive/flint-v1.0:free", input: ["text"] },
|
||||||
|
{ id: "xingchen-agi/xing4.0-29b-a4b:free", input: ["text"] },
|
||||||
|
{ id: "nvidia/nemotron-3.5-super-vl:free", input: ["text", "image"] },
|
||||||
|
{ id: "openrouter/free", input: ["text", "image"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("native 1.4 OpenRouter free catalog (offline, no live discovery)", { concurrency: false }, () => {
|
||||||
|
for (const { id, input } of specimens) {
|
||||||
|
it(`${id}: shared listing, validation, tool-capable engine, native modality, and zero cost`, async () => {
|
||||||
|
const before = process.env;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
process.env = { NODE_ENV: "test", HOME: "/nonexistent/free-catalog", OPENROUTER_API_KEY: "dummy-free-catalog" };
|
||||||
|
try {
|
||||||
|
const catalog = modelCatalog();
|
||||||
|
const listed = catalog.models.find((m) => m.provider === "openrouter" && m.id === id);
|
||||||
|
assert.ok(listed);
|
||||||
|
assert.deepEqual(listed.input, input);
|
||||||
|
assert.deepEqual(listed.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||||
|
const model = { provider: "openrouter", id };
|
||||||
|
assert.deepEqual(validateChatSettings({ model }), { ok: true });
|
||||||
|
assert.deepEqual(validateModelsSettings({ strong: { model }, cheap: { model } }), { ok: true });
|
||||||
|
assert.equal(validateModelsSettings({ vision: { model } }).ok, true, "server catalog validation stays provider/model based; vision filtering belongs to the picker");
|
||||||
|
assert.equal(catalog.models.filter((m) => m.input.includes("image")).some((m) => m.provider === "openrouter" && m.id === id), input.includes("image"));
|
||||||
|
const engine = piEngineForSpec(model);
|
||||||
|
assert.equal(engine.model, piModels().getModel("openrouter", id));
|
||||||
|
assert.equal(engine.model.api, "openai-completions");
|
||||||
|
assert.deepEqual(engine.model.input, input);
|
||||||
|
assert.equal(callCost({ inputTokens: 1_000_000, outputTokens: 500_000, cacheReadTokens: 100_000, cacheWriteTokens: 50_000 }, `openrouter:${id}`), 0);
|
||||||
|
let payload: Record<string, unknown> | undefined;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const request = new Request(input, init);
|
||||||
|
assert.equal(new URL(request.url).origin, "https://openrouter.ai");
|
||||||
|
assert.equal(request.headers.get("authorization"), "Bearer dummy-free-catalog");
|
||||||
|
payload = JSON.parse(await request.text());
|
||||||
|
return new Response("{}", { status: 400, headers: { "content-type": "application/json" } });
|
||||||
|
};
|
||||||
|
await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }], tools: [{ name: "offline_tool", description: "offline", parameters: Type.Object({}) }] }), { sessionId: "chat_free_offline" }).result();
|
||||||
|
assert.equal(payload?.model, id);
|
||||||
|
assert.match(JSON.stringify(payload?.tools), /offline_tool/);
|
||||||
|
delete process.env.OPENROUTER_API_KEY;
|
||||||
|
assert.ok(!modelCatalog().models.some((m) => m.provider === "openrouter"), "even free models retain the provider key gate");
|
||||||
|
} finally { process.env = before; globalThis.fetch = originalFetch; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("removed/unknown native models remain unpriced and unresolvable, never aliased to a paid fallback", () => {
|
||||||
|
assert.equal(callCost({ inputTokens: 1, outputTokens: 1 }, "openrouter:removed/no-such-model"), null);
|
||||||
|
assert.throws(() => piEngineForSpec({ provider: "openrouter", id: "removed/no-such-model" }), /catalog has no model/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ import {
|
|||||||
PRACTICE_PROBLEM_TOOL,
|
PRACTICE_PROBLEM_TOOL,
|
||||||
type LhMessage,
|
type LhMessage,
|
||||||
} from "../src/core/native-messages.ts";
|
} from "../src/core/native-messages.ts";
|
||||||
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
|
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
||||||
|
import { concludeInputObject, concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
|
||||||
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
|
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
|
||||||
|
|
||||||
@@ -206,14 +208,15 @@ describe("the practice agent", () => {
|
|||||||
const jobs = { chatId: "c1", jobStore: null as never, spawn: (() => {}) as never, searchAvailable: () => false };
|
const jobs = { chatId: "c1", jobStore: null as never, spawn: (() => {}) as never, searchAvailable: () => false };
|
||||||
const graph = { chatStore: null as never, graphStore: null as never };
|
const graph = { chatStore: null as never, graphStore: null as never };
|
||||||
const tools = practiceTools({ ...ctxOf([]), jobs, graph });
|
const tools = practiceTools({ ...ctxOf([]), jobs, graph });
|
||||||
// research honestly absent (no search engine live); pdf always; the
|
// Both jobs remain available keylessly; the prober's two map
|
||||||
// prober's two map readers behind the settings gate.
|
// readers stay behind their existing settings gate. Practice does
|
||||||
|
// not apply the teacher/prober user tool-toggle filter.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(tools).sort(),
|
Object.keys(tools).sort(),
|
||||||
["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read"],
|
["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read", "research"],
|
||||||
);
|
);
|
||||||
const withSearch = practiceTools({ ...ctxOf([]), jobs: { ...jobs, searchAvailable: () => true }, graph });
|
const withSearch = practiceTools({ ...ctxOf([]), jobs: { ...jobs, searchAvailable: () => true }, graph });
|
||||||
assert.ok(withSearch.research, "a live engine adds research");
|
assert.ok(withSearch.research, "a live engine also keeps research");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("constructs with exactly the two practice tools", () => {
|
it("constructs with exactly the two practice tools", () => {
|
||||||
@@ -235,6 +238,92 @@ describe("the practice agent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── persistent nudges (shipped defaults, no model calls) ──────────────────────
|
||||||
|
|
||||||
|
describe("persistent practice nudges", () => {
|
||||||
|
const doctrine = readFileSync(new URL("../src/agent/prompts/practice/10-doctrine.md", import.meta.url), "utf8");
|
||||||
|
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
||||||
|
const approvedParagraphs = [
|
||||||
|
"- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.",
|
||||||
|
"**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.",
|
||||||
|
"Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("preserves the approved paragraphs in source and the effective default prompt", () => {
|
||||||
|
// Pin approved wording and Markdown, but allow incidental line wrapping.
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const paragraphs = text.split(/\n\s*\n|\n(?=- )/).map(normalizeWhitespace);
|
||||||
|
for (const approved of approvedParagraphs) {
|
||||||
|
assert.ok(paragraphs.includes(approved), `missing approved paragraph: ${approved}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not compose the retired attempt cutoff, failed-repair reveal, or automatic-abort instructions", () => {
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const normalized = normalizeWhitespace(text);
|
||||||
|
assert.doesNotMatch(normalized, /\b(?:one|single|1) bounded attempt\b/i);
|
||||||
|
assert.doesNotMatch(normalized, /If the repair attempt fails, reveal and move on/i);
|
||||||
|
assert.doesNotMatch(normalized, /A correction found by the learner after a confident error consolidates more/i);
|
||||||
|
assert.doesNotMatch(normalized, /when the trace shows genuine inability, drop straight to R5 and reveal/i);
|
||||||
|
assert.doesNotMatch(normalized, /when the learner surrenders or cannot proceed/i);
|
||||||
|
assert.doesNotMatch(normalized, /when the marginal yield of staying is exhausted/i);
|
||||||
|
assert.doesNotMatch(normalized, /do not withhold them past the point of exhaustion/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the approved concluded-problem switch and unbounded structural repair in source and the effective default prompt", () => {
|
||||||
|
const approvedDifficulty = "3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.";
|
||||||
|
const approvedClassification = "Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.";
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const normalized = normalizeWhitespace(text);
|
||||||
|
assert.ok(normalized.includes(approvedDifficulty), "preserve the difficulty readout and exact approved switching rule");
|
||||||
|
assert.ok(normalized.includes(approvedClassification), "preserve the miss taxonomy and exact approved structural-repair rule");
|
||||||
|
assert.doesNotMatch(normalized, /two misses in a row, switch targets/i);
|
||||||
|
assert.doesNotMatch(normalized, /Structural gets one surgical line/i);
|
||||||
|
assert.doesNotMatch(normalized, /the correction, stated once/i);
|
||||||
|
assert.doesNotMatch(normalized, /if a repair needs more than a line/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the approved misconception introduction without an affirmative one-line repair cap", () => {
|
||||||
|
const approvedIntroduction = "- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.";
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const normalized = normalizeWhitespace(text);
|
||||||
|
assert.ok(normalized.includes(approvedIntroduction), "preserve the exact approved introduction and the rest of the not-teaching bullet");
|
||||||
|
assert.doesNotMatch(normalized, /Repair them in a line, surgically, and return to practice\./i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the many-claims default, hint ladder, dismissal, and explicit-go-ahead pause", () => {
|
||||||
|
const prompt = normalizeWhitespace(composeInstructions("practice"));
|
||||||
|
assert.match(prompt, /A problem may take many claims; staying is the default/);
|
||||||
|
for (const rung of ["R0", "R1", "R2", "R3", "R4", "R5"]) {
|
||||||
|
assert.ok(prompt.includes(`**${rung} —`));
|
||||||
|
}
|
||||||
|
assert.match(prompt, /Concluding ends your turn; the next problem is posed only when the learner explicitly moves on/);
|
||||||
|
assert.match(prompt, /After the conclude's result.*END YOUR TURN: no further tool calls in that reply, never another problem/);
|
||||||
|
assert.match(prompt, /A clarification question is never the go-ahead; when in doubt, answer and wait/);
|
||||||
|
assert.match(prompt, /The learner may discard a problem without concluding it/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aligns the conclude description with the canonical ack without changing outcomes or the runtime pause mechanism", async () => {
|
||||||
|
const tool = concludeTool(createPracticeGate(ctxOf(activeProblemRecord())));
|
||||||
|
const description = normalizeWhitespace(tool.description);
|
||||||
|
assert.match(description, /records the outcome \+ verdict, reveals the committed solution, and frees the learner's composer/);
|
||||||
|
assert.match(description, /After the result, say your closing words about the learner's work if useful, then END YOUR TURN — no further tool calls/);
|
||||||
|
assert.ok(description.includes(CONCLUDE_ACK_TEXT.slice(CONCLUDE_ACK_TEXT.indexOf("The next problem"))));
|
||||||
|
assert.match(description, /Nothing may conclude unless a problem is active/);
|
||||||
|
assert.doesNotMatch(description, /turn CONTINUES|reveal, then pose the next problem/);
|
||||||
|
assert.deepEqual(concludeInputObject.properties.outcome.anyOf.map((outcome) => outcome.const), [
|
||||||
|
"solved", "solvedWithHints", "movedOn", "surrendered", "dismissed",
|
||||||
|
]);
|
||||||
|
const result = await tool.execute("call_persistent_nudges", CONCLUDE_INPUT as never);
|
||||||
|
assert.equal((result.content[0] as { text: string }).text, CONCLUDE_ACK_TEXT);
|
||||||
|
assert.equal(result.terminate, undefined, "closing prose stays possible; the pause remains an instruction, not a new runtime gate");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── the prompt composition ──────────────────────────────────────────────────
|
// ── the prompt composition ──────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("practice prompt composition (own directory + the formatting appendix)", () => {
|
describe("practice prompt composition (own directory + the formatting appendix)", () => {
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Raw-stream credential regression: the Sept 26 Agent getApiKey-hook tests
|
||||||
|
* do not exercise these two hand-rolled loops. All credentials here are
|
||||||
|
* synthetic; fetch is CLOSED (never delegates), including Google's global
|
||||||
|
* fetch-only adapter. The transport cases test SDK serialization, not accounts.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
createFauxCore, createModels, fauxAssistantMessage, fauxText, fauxToolCall,
|
||||||
|
type JsonObject, type TranscriptContext, type Usage,
|
||||||
|
} from "@earendil-works/pi-ai";
|
||||||
|
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
||||||
|
import { APP_IDENTITY, piEngineForSpec, type PiEngine } from "../src/agent/models.ts";
|
||||||
|
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||||
|
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
|
||||||
|
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||||
|
import { AUTH_PROVIDER_KEYS } from "../src/core/auth-keys.ts";
|
||||||
|
import { providerEventsDir } from "../src/agent/provider-events.ts";
|
||||||
|
|
||||||
|
const OR_KEY = "dummy-openrouter-MUST-NOT-REACH-other-endpoints";
|
||||||
|
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect width="20" height="20" fill="white"/></svg>';
|
||||||
|
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
||||||
|
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", "base64");
|
||||||
|
const FINAL = "Verified offline output (p. 1). Pages consulted: 1";
|
||||||
|
const LOOPS = ["visual", "pdf"] as const;
|
||||||
|
type Loop = typeof LOOPS[number];
|
||||||
|
type Call = { name: string; args: JsonObject };
|
||||||
|
|
||||||
|
interface Transport {
|
||||||
|
provider: string; id: string; envVar: string; api: string;
|
||||||
|
origin: string; path: string; authHeader: string;
|
||||||
|
}
|
||||||
|
const TRANSPORTS: Transport[] = [
|
||||||
|
{ provider: "opencode-go", id: "claude-haiku-5-5", envVar: "OPENCODE_API_KEY", api: "anthropic-messages", origin: "https://opencode.ai", path: "/inference/go/v1/messages", authHeader: "x-api-key" },
|
||||||
|
{ provider: "opencode-go", id: "gemini-4-argon-contributor", envVar: "OPENCODE_API_KEY", api: "google-generative-ai", origin: "https://opencode.ai", path: "/inference/go/v1/models/gemini-4-argon-contributor:streamGenerateContent", authHeader: "x-goog-api-key" },
|
||||||
|
{ provider: "opencode-go", id: "glm-5.3", envVar: "OPENCODE_API_KEY", api: "openai-completions", origin: "https://opencode.ai", path: "/inference/go/v1/chat/completions", authHeader: "authorization" },
|
||||||
|
{ provider: "opencode-go", id: "gpt-6-luna", envVar: "OPENCODE_API_KEY", api: "openai-responses", origin: "https://opencode.ai", path: "/inference/go/v1/responses", authHeader: "authorization" },
|
||||||
|
{ provider: "openrouter", id: "openai/gpt-4o-mini", envVar: "OPENROUTER_API_KEY", api: "openai-completions", origin: "https://openrouter.ai", path: "/api/v1/chat/completions", authHeader: "authorization" },
|
||||||
|
{ provider: "openai", id: "gpt-4o-mini", envVar: "OPENAI_API_KEY", api: "openai-responses", origin: "https://api.openai.com", path: "/v1/responses", authHeader: "authorization" },
|
||||||
|
{ provider: "anthropic", id: "claude-haiku-4-5", envVar: "ANTHROPIC_API_KEY", api: "anthropic-messages", origin: "https://api.anthropic.com", path: "/v1/messages", authHeader: "x-api-key" },
|
||||||
|
{ provider: "google", id: "gemini-2.5-flash", envVar: "GEMINI_API_KEY", api: "google-generative-ai", origin: "https://generativelanguage.googleapis.com", path: "/v1beta/models/gemini-2.5-flash:streamGenerateContent", authHeader: "x-goog-api-key" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Replacing rather than extending env also isolates secondary tokens (including
|
||||||
|
// ANTHROPIC_AUTH_TOKEN/OAUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN), SDK base-URL
|
||||||
|
// overrides, cloud credentials, profiles, and LH_IGNORE_ENV_KEYS. No .env loads.
|
||||||
|
async function isolated<T>(env: Record<string, string>, run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = process.env;
|
||||||
|
const fetch = globalThis.fetch;
|
||||||
|
const previousEventsDir = providerEventsDir.current;
|
||||||
|
const eventsDir = mkdtempSync(path.join(tmpdir(), "lh-provider-neutral-"));
|
||||||
|
providerEventsDir.current = eventsDir;
|
||||||
|
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected fetch"); };
|
||||||
|
process.env = { NODE_ENV: "test", HOME: "/nonexistent/provider-neutral-test-home", ...env };
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
process.env = previous;
|
||||||
|
globalThis.fetch = fetch;
|
||||||
|
providerEventsDir.current = previousEventsDir;
|
||||||
|
rmSync(eventsDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callsFor(loop: Loop, round: number): Call[] {
|
||||||
|
if (round === 2) return [];
|
||||||
|
if (loop === "visual") return round === 0
|
||||||
|
? [{ name: "write_svg", args: { svg: SVG } }]
|
||||||
|
: [{ name: "render_svg", args: {} }];
|
||||||
|
return round === 0
|
||||||
|
? [{ name: "pdf_info", args: {} }]
|
||||||
|
: [{ name: "pdf_extract", args: { pages: "1" } }, { name: "pdf_render", args: { pages: "1", dpi: 100 } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeHeadless(renders: string[]): HeadlessApi {
|
||||||
|
return {
|
||||||
|
available: () => true,
|
||||||
|
unavailableReason: () => null,
|
||||||
|
runDemo: async () => { throw new Error("unexpected demo"); },
|
||||||
|
renderSvg: async (svg) => {
|
||||||
|
assert.equal(svg, SVG);
|
||||||
|
renders.push("svg");
|
||||||
|
return PNG;
|
||||||
|
},
|
||||||
|
renderPdfPages: async (file, pages, opts) => {
|
||||||
|
assert.equal(file, FIXTURE);
|
||||||
|
assert.deepEqual(pages, [1]);
|
||||||
|
assert.equal(opts?.dpi, 100);
|
||||||
|
renders.push("pdf:1");
|
||||||
|
return [{ page: 1, png: PNG }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLoop(loop: Loop, engine: PiEngine, signal = new AbortController().signal) {
|
||||||
|
const renders: string[] = [];
|
||||||
|
const activities: string[] = [];
|
||||||
|
const usages: Usage[] = [];
|
||||||
|
const opts = {
|
||||||
|
engine, headless: fakeHeadless(renders), abortSignal: signal,
|
||||||
|
onActivity: (line: string) => activities.push(line),
|
||||||
|
onUsage: (usage: Usage) => usages.push(usage),
|
||||||
|
// Direct recording cases enable the observer into an isolated temp dir.
|
||||||
|
chatId: "provider-neutral-offline",
|
||||||
|
};
|
||||||
|
const result = loop === "visual"
|
||||||
|
? await runVisualMaker({ brief: "A white square, no external resources" }, opts)
|
||||||
|
: await runPdfReaderAgent({ path: FIXTURE, question: "Read page 1" }, opts);
|
||||||
|
return { result, renders, activities, usages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSuccess(loop: Loop, run: Awaited<ReturnType<typeof runLoop>>) {
|
||||||
|
if ("svg" in run.result) {
|
||||||
|
assert.equal(run.result.svg, SVG, JSON.stringify(run.result));
|
||||||
|
assert.equal(run.result.notes, FINAL);
|
||||||
|
} else {
|
||||||
|
assert.equal(run.result.brief, FINAL);
|
||||||
|
assert.equal(run.result.path, FIXTURE);
|
||||||
|
assert.deepEqual(run.result.pagesRead, [1]);
|
||||||
|
assert.deepEqual(run.result.pagesConsulted, [1]);
|
||||||
|
}
|
||||||
|
assert.deepEqual(run.renders, [loop === "visual" ? "svg" : "pdf:1"]);
|
||||||
|
assert.equal(run.usages.length, 3, "one usage callback per successful stream round");
|
||||||
|
assert.ok(run.activities.some((line) => line.startsWith(loop === "visual" ? "write_svg:" : "pdf_info:")));
|
||||||
|
assert.ok(run.activities.some((line) => line.startsWith(loop === "visual" ? "render_svg:" : "pdf_render:")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// All global/env cases in this file are deliberately serial.
|
||||||
|
describe("provider-neutral raw agent loops", { concurrency: false }, () => {
|
||||||
|
for (const loop of LOOPS) {
|
||||||
|
it(`${loop}: every actual stream request has NO credential override (recording faux engine)`, async () => {
|
||||||
|
await isolated({ OPENROUTER_API_KEY: OR_KEY, LH_PROVIDER_EVENTS: "1" }, async () => {
|
||||||
|
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
|
||||||
|
faux.setResponses([0, 1, 2, 0, 1, 2].map((round) => {
|
||||||
|
const calls = callsFor(loop, round);
|
||||||
|
return fauxAssistantMessage(calls.length
|
||||||
|
? calls.map((c, i) => fauxToolCall(c.name, c.args, { id: `call_${round}_${i}` }))
|
||||||
|
: [fauxText(FINAL)], { stopReason: calls.length ? "toolUse" : "stop" });
|
||||||
|
}));
|
||||||
|
const requests: { context: TranscriptContext; options: Record<string, unknown> }[] = [];
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
const engine: PiEngine = {
|
||||||
|
model: faux.getModel(), level: "medium",
|
||||||
|
streamFn: (model, context, options) => {
|
||||||
|
requests.push({ context, options: { ...options } });
|
||||||
|
return faux.streamSimple(model, context, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const run = await runLoop(loop, engine, signal);
|
||||||
|
assertSuccess(loop, run);
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
const sessionId = requests[0]!.options.sessionId;
|
||||||
|
assert.match(String(sessionId), /^[0-9a-f-]{36}$/);
|
||||||
|
assert.notEqual(sessionId, "provider-neutral-offline");
|
||||||
|
for (const { options } of requests) {
|
||||||
|
assert.equal(options.sessionId, sessionId);
|
||||||
|
for (const key of ["apiKey", "getApiKey", "auth", "headers"]) {
|
||||||
|
assert.ok(!Object.hasOwn(options, key), `${loop} stream must not supply ${key}`);
|
||||||
|
}
|
||||||
|
assert.equal(options.reasoning, "medium");
|
||||||
|
assert.equal(options.signal, signal);
|
||||||
|
assert.equal(typeof options.onProviderStreamEvent, "function");
|
||||||
|
}
|
||||||
|
assert.match(JSON.stringify(requests[2]!.context), /image\/png/);
|
||||||
|
assert.match(JSON.stringify(requests[2]!.context), /toolResult/);
|
||||||
|
const second = await runLoop(loop, engine, signal);
|
||||||
|
assertSuccess(loop, second);
|
||||||
|
assert.equal(requests.length, 6);
|
||||||
|
const secondId = requests[3]!.options.sessionId;
|
||||||
|
assert.notEqual(secondId, sessionId, "a separate unsaved operation must not reuse a conversation");
|
||||||
|
for (const request of requests.slice(3)) assert.equal(request.options.sessionId, secondId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${loop}: abort still reaches the stream and retains the loop's failure semantics`, async () => {
|
||||||
|
await isolated({ OPENROUTER_API_KEY: OR_KEY }, async () => {
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
faux.setResponses([fauxAssistantMessage([fauxText("must not be delivered")])]);
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
const run = await runLoop(loop, { model: faux.getModel(), streamFn: faux.streamSimple }, controller.signal);
|
||||||
|
const failure = "svg" in run.result ? run.result.notes : run.result.brief;
|
||||||
|
assert.match(failure ?? "", /crashed — aborted/);
|
||||||
|
assert.deepEqual(run.renders, []);
|
||||||
|
assert.equal(run.usages.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transport of TRANSPORTS) {
|
||||||
|
for (const loop of LOOPS) {
|
||||||
|
it(`${loop}: native ${transport.provider}/${transport.api} endpoint, payload and live per-request auth`, async () => {
|
||||||
|
await isolated({ OPENROUTER_API_KEY: OR_KEY, ...(transport.provider === "openrouter" ? {} : { [transport.envVar]: "dummy-selected-key-first" }) }, async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: transport.provider, id: transport.id, thinkingLevel: "off" });
|
||||||
|
assert.equal(engine.model.api, transport.api);
|
||||||
|
const requests: RecordedRequest[] = [];
|
||||||
|
globalThis.fetch = closedFetch(transport, loop, requests);
|
||||||
|
const run = await runLoop(loop, engine);
|
||||||
|
assertSuccess(loop, run);
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
for (const [round, request] of requests.entries()) {
|
||||||
|
assert.equal(request.headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
|
||||||
|
if (transport.provider === "opencode-go") {
|
||||||
|
assert.match(request.headers.get("x-opencode-session") ?? "", /^[0-9a-f-]{36}$/);
|
||||||
|
assert.equal(request.headers.get("x-opencode-session"), requests[0]!.headers.get("x-opencode-session"));
|
||||||
|
assert.equal(request.headers.get("x-opencode-client"), "learnharness");
|
||||||
|
}
|
||||||
|
const url = new URL(request.url);
|
||||||
|
assert.equal(url.origin, transport.origin);
|
||||||
|
assert.equal(url.pathname, transport.path);
|
||||||
|
assert.equal(request.method, "POST");
|
||||||
|
const expected = transport.provider === "openrouter" ? OR_KEY : round === 0 ? "dummy-selected-key-first" : "dummy-selected-key-rotated";
|
||||||
|
assert.equal(request.headers.get(transport.authHeader), transport.authHeader === "authorization" ? `Bearer ${expected}` : expected);
|
||||||
|
if (transport.provider !== "openrouter") assert.ok(!JSON.stringify({ ...request, headers: Object.fromEntries(request.headers) }).includes(OR_KEY));
|
||||||
|
assertPayload(transport, loop, round, request.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (transport.provider !== "openrouter") {
|
||||||
|
it(`${loop}: ${transport.provider} missing selected key does not fall back to configured OpenRouter`, async () => {
|
||||||
|
await isolated({ OPENROUTER_API_KEY: OR_KEY }, async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
globalThis.fetch = async () => { fetches++; throw new Error("CLOSED: no fetch allowed"); };
|
||||||
|
const engine = piEngineForSpec({ provider: transport.provider, id: transport.id, thinkingLevel: "off" });
|
||||||
|
const run = await runLoop(loop, engine);
|
||||||
|
const failure = "svg" in run.result ? run.result.notes : run.result.brief;
|
||||||
|
assert.match(failure ?? "", new RegExp(`Provider is not configured: ${transport.provider}`));
|
||||||
|
assert.equal(fetches, 0, "SDK Models guard rejects missing selected-provider auth before transport");
|
||||||
|
assert.equal(run.usages.length, 0);
|
||||||
|
assert.deepEqual(run.renders, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("all AUTH_PROVIDER_KEYS resolve their own plain-key env values in a fresh in-memory Models collection", async () => {
|
||||||
|
// No production singleton, credential files, process.env, OAuth refresh,
|
||||||
|
// or model refresh: this is a broad resolver contract, not wire coverage.
|
||||||
|
await isolated({}, async () => {
|
||||||
|
const env = Object.fromEntries(AUTH_PROVIDER_KEYS.map(({ envVar }) => [envVar, `dummy-resolver-${envVar}`]));
|
||||||
|
const models = createModels({ authContext: { env: async (name) => env[name], fileExists: async () => false } });
|
||||||
|
for (const provider of builtinProviders()) models.setProvider(provider);
|
||||||
|
for (const { id, envVar } of AUTH_PROVIDER_KEYS) {
|
||||||
|
const resolved = await models.getAuth(id);
|
||||||
|
assert.ok(resolved, id);
|
||||||
|
assert.equal(resolved.source, envVar, id);
|
||||||
|
assert.equal(resolved.auth.apiKey, env[envVar], id);
|
||||||
|
}
|
||||||
|
// In particular, intentionally shared variables must serve BOTH rows.
|
||||||
|
for (const envVar of new Set(AUTH_PROVIDER_KEYS.map((p) => p.envVar))) {
|
||||||
|
const siblings = AUTH_PROVIDER_KEYS.filter((p) => p.envVar === envVar);
|
||||||
|
if (siblings.length < 2) continue;
|
||||||
|
for (const { id } of siblings) assert.equal((await models.getAuth(id))?.auth.apiKey, env[envVar], id);
|
||||||
|
}
|
||||||
|
delete env.OPENAI_API_KEY;
|
||||||
|
assert.equal(await models.getAuth("openai"), undefined, "OpenRouter cannot configure a different provider");
|
||||||
|
env.OPENAI_API_KEY = "dummy-resolver-openai-rotated";
|
||||||
|
assert.equal((await models.getAuth("openai"))?.auth.apiKey, env.OPENAI_API_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RecordedRequest {
|
||||||
|
url: string; method: string; headers: Headers; body: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedFetch(transport: Transport, loop: Loop, requests: RecordedRequest[]): typeof fetch {
|
||||||
|
return async (input, init) => {
|
||||||
|
const request = new Request(input, init);
|
||||||
|
const round = requests.length;
|
||||||
|
// Unexpected destinations/rounds fail closed; never call original fetch.
|
||||||
|
assert.equal(new URL(request.url).origin, transport.origin);
|
||||||
|
assert.ok(round < 3, "unexpected extra provider request");
|
||||||
|
requests.push({ url: request.url, method: request.method, headers: request.headers, body: JSON.parse(await request.text()) });
|
||||||
|
if (transport.provider !== "openrouter") process.env[transport.envVar] = "dummy-selected-key-rotated";
|
||||||
|
return sseResponse(transport, callsFor(loop, round), round);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPayload(transport: Transport, loop: Loop, round: number, body: Record<string, unknown>) {
|
||||||
|
if (transport.api !== "google-generative-ai") assert.equal(body.model, transport.id);
|
||||||
|
// Native Responses models may declare tools in additional_tools input
|
||||||
|
// items rather than top-level tools; assert the SDK's complete payload.
|
||||||
|
const tools = JSON.stringify(body);
|
||||||
|
assert.match(tools, new RegExp(loop === "visual" ? "write_svg" : "pdf_extract"));
|
||||||
|
assert.match(tools, new RegExp(loop === "visual" ? "render_svg" : "pdf_render"));
|
||||||
|
if (transport.api === "google-generative-ai") assert.ok(Array.isArray(body.contents));
|
||||||
|
else if (transport.api === "openai-responses") assert.ok(Array.isArray(body.input));
|
||||||
|
else assert.ok(Array.isArray(body.messages));
|
||||||
|
const json = JSON.stringify(body);
|
||||||
|
assert.match(json, new RegExp(loop === "visual" ? "white square" : "Read page 1"));
|
||||||
|
if (round === 2) {
|
||||||
|
const supportsImages = piEngineForSpec({ provider: transport.provider, id: transport.id }).model.input.includes("image");
|
||||||
|
assert.equal(json.includes(PNG.toString("base64")), supportsImages, "native modality filtering: only vision models receive rendered images");
|
||||||
|
assert.match(json, new RegExp(transport.api === "google-generative-ai" ? "functionResponse" : transport.api === "openai-responses" ? "function_call_output" : transport.api === "anthropic-messages" ? "tool_result" : '"role":"tool"'));
|
||||||
|
assert.match(json, new RegExp(loop === "visual" ? "Rendered" : "Page 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sseResponse(transport: Transport, calls: Call[], round: number): Response {
|
||||||
|
const events: Record<string, unknown>[] = [];
|
||||||
|
if (transport.api === "openai-completions") {
|
||||||
|
events.push({ id: `chatcmpl_offline_${round}`, object: "chat.completion.chunk", created: 1, model: transport.id,
|
||||||
|
choices: [{ index: 0, delta: calls.length ? { role: "assistant", tool_calls: calls.map((c, i) => ({ index: i, id: `call_${round}_${i}`, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args) } })) } : { role: "assistant", content: FINAL }, finish_reason: null }] });
|
||||||
|
events.push({ choices: [{ index: 0, delta: {}, finish_reason: calls.length ? "tool_calls" : "stop" }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } });
|
||||||
|
} else if (transport.api === "openai-responses") {
|
||||||
|
const output = calls.length ? calls.map((c, i) => ({ type: "function_call", id: `fc_${round}_${i}`, call_id: `call_${round}_${i}`, name: c.name, arguments: JSON.stringify(c.args), status: "completed" }))
|
||||||
|
: [{ type: "message", id: `msg_${round}`, role: "assistant", status: "completed", content: [{ type: "output_text", text: FINAL, annotations: [] }] }];
|
||||||
|
events.push({ type: "response.created", response: { id: `resp_offline_${round}`, status: "in_progress" } });
|
||||||
|
for (const [index, item] of output.entries()) {
|
||||||
|
events.push({ type: "response.output_item.added", output_index: index, item: { ...item, status: "in_progress", ...(calls.length ? { arguments: "" } : { content: [] }) } });
|
||||||
|
events.push(calls.length
|
||||||
|
? { type: "response.function_call_arguments.delta", output_index: index, delta: JSON.stringify(calls[index]!.args) }
|
||||||
|
: { type: "response.output_text.delta", output_index: index, content_index: 0, delta: FINAL });
|
||||||
|
events.push({ type: "response.output_item.done", output_index: index, item });
|
||||||
|
}
|
||||||
|
events.push({ type: "response.completed", response: { id: `resp_offline_${round}`, status: "completed", output, usage: { input_tokens: 20, output_tokens: 10, total_tokens: 30 } } });
|
||||||
|
} else if (transport.api === "anthropic-messages") {
|
||||||
|
events.push({ type: "message_start", message: { id: `msg_offline_${round}`, type: "message", role: "assistant", model: transport.id, content: [], stop_reason: null, usage: { input_tokens: 20, output_tokens: 0 } } });
|
||||||
|
const blocks = calls.length ? calls.map((c, i) => ({ type: "tool_use", id: `call_${round}_${i}`, name: c.name, input: {} })) : [{ type: "text", text: "" }];
|
||||||
|
for (const [index, block] of blocks.entries()) {
|
||||||
|
events.push({ type: "content_block_start", index, content_block: block });
|
||||||
|
events.push({ type: "content_block_delta", index, delta: calls.length ? { type: "input_json_delta", partial_json: JSON.stringify(calls[index]!.args) } : { type: "text_delta", text: FINAL } });
|
||||||
|
events.push({ type: "content_block_stop", index });
|
||||||
|
}
|
||||||
|
events.push({ type: "message_delta", delta: { stop_reason: calls.length ? "tool_use" : "end_turn" }, usage: { output_tokens: 10 } });
|
||||||
|
events.push({ type: "message_stop" });
|
||||||
|
} else {
|
||||||
|
events.push({ responseId: `google_offline_${round}`, candidates: [{ index: 0, content: { role: "model", parts: calls.length ? calls.map((c, i) => ({ functionCall: { id: `call_${round}_${i}`, name: c.name, args: c.args } })) : [{ text: FINAL }] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 10, totalTokenCount: 30 } });
|
||||||
|
}
|
||||||
|
const data = events.map((event) => `${typeof event.type === "string" ? `event: ${event.type}\n` : ""}data: ${JSON.stringify(event)}\n\n`).join("") + (transport.api === "openai-completions" ? "data: [DONE]\n\n" : "");
|
||||||
|
return new Response(data, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const script = fileURLToPath(new URL("../scripts/smoke-provider-runtime.mjs", import.meta.url));
|
||||||
|
const checkout = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 });
|
||||||
|
|
||||||
|
describe("offline native Pi Google initialization gate", () => {
|
||||||
|
it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => {
|
||||||
|
// This is an unbundled positive control, NOT proof of standalone completeness.
|
||||||
|
const result = run(checkout);
|
||||||
|
assert.equal(result.status, 0, String(result.stderr));
|
||||||
|
for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`));
|
||||||
|
assert.match(String(result.stdout), /zero network attempts/);
|
||||||
|
});
|
||||||
|
it("rejects a missing Pi entry instead of treating any error as success", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-missing-"));
|
||||||
|
try {
|
||||||
|
const result = run(root);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.doesNotMatch(String(result.stdout), /initialization passed/);
|
||||||
|
assert.match(String(result.stderr), /MODULE_NOT_FOUND|ENOENT/);
|
||||||
|
assert.deepEqual(fs.readdirSync(root), [], "gate modified the payload");
|
||||||
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
it("rejects an otherwise complete SDK outside the payload (no host fallback)", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-escape-"));
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(path.join(checkout, "node_modules"), path.join(root, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
||||||
|
const result = run(root);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(String(result.stderr), /dependency escaped standalone payload/);
|
||||||
|
assert.doesNotMatch(String(result.stdout), /initialization passed/);
|
||||||
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/** JS matcher + config-policy regressions; native Turbopack needs the real payload gate. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import nextConfig from "../next.config.ts";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
// Use Next's actual bundled matcher, not a different glob implementation.
|
||||||
|
const picomatch = require("next/dist/compiled/picomatch");
|
||||||
|
const { makeIgnoreFn } = require("next/dist/build/collect-build-traces");
|
||||||
|
const patterns = nextConfig.outputFileTracingExcludes!["*"];
|
||||||
|
|
||||||
|
describe("standalone JavaScript tracing exclusions and native workaround policy", () => {
|
||||||
|
for (const windows of [false, true]) {
|
||||||
|
const root = windows ? "C:\\build\\learnharness" : "/build/learnharness";
|
||||||
|
const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative);
|
||||||
|
const ignored = picomatch(patterns, { contains: true, dot: true, windows });
|
||||||
|
it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => {
|
||||||
|
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) {
|
||||||
|
assert.equal(ignored(file(relative)), true, relative);
|
||||||
|
}
|
||||||
|
// The explicit includes remain necessary: this raw ignore still matches SDK data.
|
||||||
|
assert.equal(ignored(file("node_modules/@earendil-works/pi-ai/dist/providers/data/models.json")), true);
|
||||||
|
assert.ok(nextConfig.outputFileTracingIncludes!["*"]!.includes("node_modules/@earendil-works/pi-ai/dist/providers/data/**/*"));
|
||||||
|
});
|
||||||
|
it(`does not confuse dependency suffixes with data directories (${windows ? "Windows" : "POSIX"})`, () => {
|
||||||
|
for (const relative of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/form-data/lib/form_data.js", "node_modules/@swc/helpers/_/_ts_metadata.js", "node_modules/@swc/helpers/_/_ts_metadata/index.js", "node_modules/somedata/runtime.js"]) {
|
||||||
|
assert.equal(ignored(file(relative)), false, relative);
|
||||||
|
assert.equal(picomatch("data/**/*", { contains: true, dot: true, windows })(file("node_modules/gcp-metadata/build/src/index.js")), true, "old pattern must reproduce the bug");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("uses the installed Next JavaScript early-ignore function on the host platform", () => {
|
||||||
|
const root = process.cwd();
|
||||||
|
const ignore = makeIgnoreFn(root, patterns);
|
||||||
|
assert.equal(ignore(`${root}/data/private.json`), true);
|
||||||
|
assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false);
|
||||||
|
});
|
||||||
|
it("preserves all secret exclusions exactly", () => {
|
||||||
|
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]);
|
||||||
|
});
|
||||||
|
it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => {
|
||||||
|
assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [
|
||||||
|
"node_modules/@puppeteer/browsers/lib/browser-data/**/*.js",
|
||||||
|
"node_modules/pdfjs-dist/legacy/**/*",
|
||||||
|
"node_modules/pdfjs-dist/standard_fonts/**/*",
|
||||||
|
"node_modules/@napi-rs/canvas-*/**/*",
|
||||||
|
"node_modules/@resvg/resvg-js-*/**/*",
|
||||||
|
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||||
|
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||||
|
"node_modules/gcp-metadata/**/*",
|
||||||
|
"node_modules/json-bigint/**/*",
|
||||||
|
"node_modules/bignumber.js/**/*",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("limits the workaround to its three packages, never learner data or unrelated dependencies", () => {
|
||||||
|
const closure = nextConfig.outputFileTracingIncludes!["*"]!.slice(-3);
|
||||||
|
const included = picomatch(closure, { dot: true });
|
||||||
|
for (const file of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/json-bigint/lib/parse.js", "node_modules/bignumber.js/bignumber.js"]) assert.equal(included(file), true, file);
|
||||||
|
for (const file of ["data/private.json", "nested/data/private.json", "auth.json", ".env.local", "node_modules/unrelated/runtime.js", "node_modules/gcp-metadata-lookalike/index.js"]) assert.equal(included(file), false, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
|
|||||||
assert.ok(!html.includes("settings-dialog"));
|
assert.ok(!html.includes("settings-dialog"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the dialog frame: role=dialog + the ✕ + the six-section nav, General active", () => {
|
it("the dialog frame: role=dialog + the ✕ + the six-section nav, Providers first and General last", () => {
|
||||||
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
|
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
|
||||||
assert.ok(html.includes('role="dialog"'));
|
assert.ok(html.includes('role="dialog"'));
|
||||||
assert.ok(html.includes('aria-modal="true"'));
|
assert.ok(html.includes('aria-modal="true"'));
|
||||||
@@ -49,8 +49,9 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
|
|||||||
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) {
|
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) {
|
||||||
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
|
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
|
||||||
}
|
}
|
||||||
// General is the landing section; the others are not current.
|
// Providers is the first landing section; internal keys deep links survive.
|
||||||
assert.ok(tagOf(html, "settings-nav-general").includes('aria-current="true"'));
|
assert.ok(tagOf(html, "settings-nav-keys").includes('aria-current="true"'));
|
||||||
|
assert.ok(html.indexOf('data-testid="settings-nav-keys"') < html.indexOf('data-testid="settings-nav-general"'));
|
||||||
assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"'));
|
assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"'));
|
||||||
// Fetches don't run under SSR — the loading state IS the SSR state.
|
// Fetches don't run under SSR — the loading state IS the SSR state.
|
||||||
assert.ok(html.includes("Loading settings…"));
|
assert.ok(html.includes("Loading settings…"));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
|
||||||
|
import React from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { validateToolArguments } from "@earendil-works/pi-ai";
|
||||||
|
import { problemInputObject } from "../src/agent/tools/practice.ts";
|
||||||
|
import { PracticeDock } from "../components/practice-dock.tsx";
|
||||||
|
import { QuestionDock } from "../components/question-dock.tsx";
|
||||||
|
import { LatexGlyph } from "../components/latex-glyph.tsx";
|
||||||
|
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
|
||||||
|
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||||
|
import {
|
||||||
|
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
|
||||||
|
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
|
||||||
|
toLlmMessages, type LhMessage, type ArtifactPartProps,
|
||||||
|
} from "../src/core/index.ts";
|
||||||
|
|
||||||
|
const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`;
|
||||||
|
const clean = `$${tex}$`;
|
||||||
|
const doubled = clean.replace(/\\/g, "\\\\");
|
||||||
|
const quadrupled = clean.replace(/\\/g, "\\\\\\\\");
|
||||||
|
const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f.
|
||||||
|
const repairedDebris = String.raw`\forall x \in \mathbb{R}`;
|
||||||
|
const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$";
|
||||||
|
const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`;
|
||||||
|
const part = (input: unknown, over: Partial<ArtifactPartProps["part"]> = {}): ArtifactPartProps["part"] => ({
|
||||||
|
type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
|
||||||
|
});
|
||||||
|
const dock = (input: { statement: string }, expanded = true) => (
|
||||||
|
<PracticeDock problemCallId="call_math" input={input} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
|
||||||
|
);
|
||||||
|
|
||||||
|
function math(html: string) {
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
return {
|
||||||
|
annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent),
|
||||||
|
glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""),
|
||||||
|
errors: document.querySelectorAll(".katex-error").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) {
|
||||||
|
const actual = math(html);
|
||||||
|
assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors");
|
||||||
|
assert.equal(actual.errors, 0);
|
||||||
|
for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("repairMathForDisplay — the existing composition, unchanged", () => {
|
||||||
|
const unchanged = [
|
||||||
|
clean,
|
||||||
|
String.raw`outside \\nabla`,
|
||||||
|
String.raw`$a \\\nabla b$`,
|
||||||
|
String.raw`$a \\\\\nabla b$`,
|
||||||
|
String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`,
|
||||||
|
"$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too.
|
||||||
|
"`" + doubled + "`",
|
||||||
|
"```tex\n" + doubled + "\n```",
|
||||||
|
"`" + debris + "`",
|
||||||
|
"```tex\n" + debris + "\n```",
|
||||||
|
String.raw`$\\nabla`,
|
||||||
|
"$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close.
|
||||||
|
];
|
||||||
|
for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix,
|
||||||
|
String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) {
|
||||||
|
it(`matches the old composition byte-for-byte (${index})`, () => {
|
||||||
|
assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => {
|
||||||
|
for (const value of unchanged) assert.equal(repairMathForDisplay(value), value);
|
||||||
|
assert.equal(repairMathForDisplay(doubled), clean);
|
||||||
|
assert.equal(repairMathForDisplay(quadrupled), clean);
|
||||||
|
assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`);
|
||||||
|
assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upgraded KaTeX rendering on real question and practice surfaces", () => {
|
||||||
|
for (const [name, formula, glyphs] of [
|
||||||
|
["fraction and root", String.raw`\frac{1}{\sqrt{x}}`, ["1", "x"]],
|
||||||
|
["accents", String.raw`\hat{x}+\bar{y}`, ["x", "y"]],
|
||||||
|
["sum", String.raw`\sum_{i=1}^{n}i`, ["∑", "i", "n"]],
|
||||||
|
["matrix rows", String.raw`\begin{pmatrix}1&2\\3&4\end{pmatrix}`, ["1", "2", "3", "4"]],
|
||||||
|
["aligned rows", String.raw`\begin{aligned}a&=b\\ c&=d\end{aligned}`, ["a", "b", "c", "d", "="]],
|
||||||
|
] as const) {
|
||||||
|
it(`${name}: dollar and legacy delimiters preserve glyphs and copied TeX`, () => {
|
||||||
|
for (const raw of [`$${formula}$`, `\\(${formula}\\)`, `$$\n${formula}\n$$`, `\\[\n${formula}\n\\]`]) {
|
||||||
|
for (const element of [
|
||||||
|
<MessageResponse mode="static">{raw}</MessageResponse>,
|
||||||
|
<QuestionDock toolName="ask_user_question" toolCallId="security_question" input={{ question: raw }} expanded onExpand={() => {}} onSubmit={() => {}} />,
|
||||||
|
dock({ statement: raw }),
|
||||||
|
]) {
|
||||||
|
const html = renderToStaticMarkup(element);
|
||||||
|
assertMath(html, [formula], [...glyphs]);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
assert.ok(document.querySelector(".katex-base .katex-strut"), "matching 0.18 internal layout classes");
|
||||||
|
if (name === "fraction and root") assert.ok(document.querySelector(".mfrac .sqrt"));
|
||||||
|
if (name === "accents") assert.ok(document.querySelectorAll(".katex-accent").length >= 2);
|
||||||
|
if (name.endsWith("rows")) assert.ok(document.querySelectorAll(".mtable .mtr").length >= 2 || document.querySelectorAll(".mtable .vlist > span").length >= 2, "two real rows, not repaired-away slashes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("preserves escaped currency and code on both dock surfaces", () => {
|
||||||
|
const raw = "Costs \\$5 and \\$10. `" + clean + "`\n\n```text\n" + doubled + "\n```";
|
||||||
|
for (const element of [dock({ statement: raw }), <QuestionDock toolName="ask_user_question" toolCallId="security_code" input={{ question: raw }} expanded onExpand={() => {}} onSubmit={() => {}} />]) {
|
||||||
|
const html = renderToStaticMarkup(element);
|
||||||
|
assert.deepEqual(math(html).annotations, []);
|
||||||
|
assert.ok(parseHTML(html).document.firstElementChild?.textContent?.includes("$5 and $10"));
|
||||||
|
assert.ok(parseHTML(html).document.querySelector("code")?.textContent?.includes(clean));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("renders direct LatexGlyph connectors through the upgraded root", () => {
|
||||||
|
for (const [command, glyph] of [[String.raw`\Rightarrow`, "⇒"], [String.raw`\land`, "∧"], [String.raw`\neg`, "¬"]]) {
|
||||||
|
const html = renderToStaticMarkup(<LatexGlyph cmd={command!} />);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
assert.equal(document.querySelectorAll(".katex-error").length, 0);
|
||||||
|
assert.ok(document.querySelector(".katex-base .katex-strut"));
|
||||||
|
assert.ok(document.querySelector(".katex-html")?.textContent?.includes(glyph!));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("practice display surfaces", () => {
|
||||||
|
for (const [name, raw, expected, glyphs] of [
|
||||||
|
["two command slashes", doubled, tex, ["∇", "∈", "×"]],
|
||||||
|
["four command slashes", quadrupled, tex, ["∇", "∈", "×"]],
|
||||||
|
["JSON debris", debris, repairedDebris, ["∀", "∈"]],
|
||||||
|
["clean math", clean, tex, ["∇", "∈", "×"]],
|
||||||
|
["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]],
|
||||||
|
] as const) {
|
||||||
|
it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => {
|
||||||
|
const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` });
|
||||||
|
const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw });
|
||||||
|
const before = JSON.stringify({ input, conclusion });
|
||||||
|
for (const element of [dock(input), <ProblemCard part={part(input)} />]) {
|
||||||
|
const html = renderToStaticMarkup(element);
|
||||||
|
assertMath(html, [expected], [...glyphs]);
|
||||||
|
assert.ok(!html.includes("HIDDEN COMMITMENT"));
|
||||||
|
}
|
||||||
|
assertMath(renderToStaticMarkup(<ConcludeCard part={part(conclusion, { type: "tool-conclude", reveal: raw })} />),
|
||||||
|
[expected, expected], [...glyphs]);
|
||||||
|
assertMath(renderToStaticMarkup(<ConcludeCard part={part({ outcome: "solved", solutionReveal: raw })} />),
|
||||||
|
[expected], [...glyphs]);
|
||||||
|
assert.equal(JSON.stringify({ input, conclusion }), before);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => {
|
||||||
|
const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." };
|
||||||
|
const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw };
|
||||||
|
const validated = validateToolArguments(
|
||||||
|
{ name: "problem", parameters: problemInputObject } as never,
|
||||||
|
call as never,
|
||||||
|
) as { statement: unknown };
|
||||||
|
assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments");
|
||||||
|
assert.equal(raw.statement, 17, "saved arguments retain the number");
|
||||||
|
const messages = [
|
||||||
|
{ id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 },
|
||||||
|
{ id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
|
||||||
|
] as unknown as LhMessage[];
|
||||||
|
const before = structuredClone(messages);
|
||||||
|
const active = activeProblemOf(messages);
|
||||||
|
assert.ok(active);
|
||||||
|
assert.equal((active.input as { statement: unknown }).statement, 17);
|
||||||
|
// Mirror the existing runtime cast at the Chat -> PracticeDock seam.
|
||||||
|
const input = active.input as { statement: string };
|
||||||
|
let expanded = "";
|
||||||
|
assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); });
|
||||||
|
const { document } = parseHTML(expanded);
|
||||||
|
assert.equal(document.querySelector(".lh-prose")!.innerHTML,
|
||||||
|
parseHTML(renderToStaticMarkup(<MessageResponse>{input.statement}</MessageResponse>)).document.firstElementChild!.outerHTML,
|
||||||
|
"non-string values pass through exactly as before, without string coercion or a new fallback");
|
||||||
|
const collapsed = renderToStaticMarkup(dock(input, false));
|
||||||
|
assert.match(collapsed, /data-testid="practice-dock-bar"/);
|
||||||
|
assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17");
|
||||||
|
assert.deepEqual(math(collapsed).annotations, []);
|
||||||
|
assert.deepEqual(messages, before);
|
||||||
|
assert.equal(raw.statement, 17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves code spans/fences across every repaired display surface", () => {
|
||||||
|
const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```";
|
||||||
|
for (const element of [dock({ statement: raw }), <ProblemCard part={part({ statement: raw })} />,
|
||||||
|
<ConcludeCard part={part({ outcome: "solved", verdict: raw }, { reveal: raw })} />]) {
|
||||||
|
const html = renderToStaticMarkup(element);
|
||||||
|
assert.deepEqual(math(html).annotations, []);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
assert.ok(document.querySelector("code")?.textContent?.includes(doubled));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change the collapsed preview, dismissal or global prose renderer", () => {
|
||||||
|
const preview = renderToStaticMarkup(dock({ statement: doubled }, false));
|
||||||
|
assert.ok(preview.includes(doubled));
|
||||||
|
assert.deepEqual(math(preview).annotations, []);
|
||||||
|
const dismissed = renderToStaticMarkup(<ConcludeCard part={part({ outcome: "dismissed", verdict: doubled }, { reveal: doubled })} />);
|
||||||
|
assert.ok(dismissed.includes("Problem dismissed."));
|
||||||
|
assert.deepEqual(math(dismissed).annotations, []);
|
||||||
|
const plain = math(renderToStaticMarkup(<MessageResponse>{doubled}</MessageResponse>));
|
||||||
|
assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]);
|
||||||
|
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
|
||||||
|
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
|
||||||
|
const ask = { question: doubled };
|
||||||
|
const explain = { question: doubled, correctAnswer: debris };
|
||||||
|
const before = structuredClone({ quiz, ask, explain });
|
||||||
|
assert.equal(parseQuizInput(quiz)?.question, clean);
|
||||||
|
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
|
||||||
|
assert.equal(parseAskInput(ask)?.question, clean);
|
||||||
|
assert.equal(parseExplainInput(explain)?.question, clean);
|
||||||
|
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
|
||||||
|
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
|
||||||
|
assert.deepEqual({ quiz, ask, explain }, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => {
|
||||||
|
const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" };
|
||||||
|
const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` };
|
||||||
|
const messages = [
|
||||||
|
{ ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] },
|
||||||
|
{ id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
|
||||||
|
] as unknown as LhMessage[];
|
||||||
|
const concluded = [...messages,
|
||||||
|
{ ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] },
|
||||||
|
{ id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 },
|
||||||
|
] as unknown as LhMessage[];
|
||||||
|
const before = structuredClone(concluded);
|
||||||
|
const history = structuredClone(toLlmMessages(concluded));
|
||||||
|
assert.deepEqual(activeProblemOf(messages)?.input, input);
|
||||||
|
const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!;
|
||||||
|
assert.ok(problem.state && problem.toolCallId);
|
||||||
|
assert.deepEqual(problem.input, input);
|
||||||
|
const statementHtml = renderToStaticMarkup(<ProblemCard part={{ ...problem, state: problem.state, toolCallId: problem.toolCallId }} />);
|
||||||
|
assertMath(statementHtml);
|
||||||
|
assert.ok(!statementHtml.includes("COMMITTED"));
|
||||||
|
const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!;
|
||||||
|
assert.ok(conclusion.state && conclusion.toolCallId);
|
||||||
|
assert.equal(conclusion.reveal, input.modelSolution);
|
||||||
|
const revealHtml = renderToStaticMarkup(<ConcludeCard part={{ ...conclusion, state: conclusion.state, toolCallId: conclusion.toolCallId }} />);
|
||||||
|
assert.ok(revealHtml.includes("COMMITTED"));
|
||||||
|
assertMath(revealHtml, [tex, tex]);
|
||||||
|
assert.deepEqual(concluded, before);
|
||||||
|
assert.deepEqual(toLlmMessages(concluded), history);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { it } from "node:test";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { AgentSection } from "../components/settings/agent-section.tsx";
|
||||||
|
import type { AgentSettings } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
it("keyless settings keep Research interactive while search-engine setup retains its own disabled state", async () => {
|
||||||
|
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const reason = "No search engine is configured.";
|
||||||
|
const bodies: Record<string, unknown> = {
|
||||||
|
"/api/search": { available: false, engine: null, reason },
|
||||||
|
"/api/keys": { keys: [{ id: "TAVILY_API_KEY", present: false }, { id: "BRAVE_API_KEY", present: false }] },
|
||||||
|
"/api/models": { models: [], slots: { strong: null, cheap: null, vision: null } },
|
||||||
|
"/api/settings/prompts": { prober: [], teacher: [] },
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
window: dom.window,
|
||||||
|
document: dom.document,
|
||||||
|
navigator: globals.navigator ?? { userAgent: "", platform: "" },
|
||||||
|
HTMLElement: dom.window.HTMLElement,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
fetch: async (url: string) => {
|
||||||
|
assert.ok(url in bodies, `unexpected request: ${url}`);
|
||||||
|
return new Response(JSON.stringify(bodies[url]));
|
||||||
|
},
|
||||||
|
})) {
|
||||||
|
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||||
|
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||||
|
}
|
||||||
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
let written: AgentSettings | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<AgentSection
|
||||||
|
agent={{ tools: { research: true } }}
|
||||||
|
models={undefined}
|
||||||
|
graph={undefined}
|
||||||
|
compaction={undefined}
|
||||||
|
onSectionChange={(next) => { written = next; }}
|
||||||
|
onModelsChange={() => {}}
|
||||||
|
onCompactionChange={() => {}}
|
||||||
|
/>);
|
||||||
|
});
|
||||||
|
const row = document.querySelector<HTMLElement>('[data-testid="tool-row-research"]')!;
|
||||||
|
const toggle = row.querySelector<HTMLButtonElement>('[data-testid="toggle-tool-research"]')!;
|
||||||
|
assert.ok(toggle);
|
||||||
|
assert.equal(toggle.hasAttribute("disabled"), false);
|
||||||
|
assert.equal(toggle.getAttribute("aria-checked"), "true");
|
||||||
|
assert.ok(!row.className.includes("opacity-40"));
|
||||||
|
assert.ok(!row.textContent?.includes(reason), "search setup hint is no longer a research-disable hint");
|
||||||
|
act(() => { toggle.click(); });
|
||||||
|
assert.equal(written?.tools?.research, false, "the keyless toggle emits the same stored boolean");
|
||||||
|
const engine = document.querySelector<HTMLButtonElement>('[data-testid="search-engine-trigger"]')!;
|
||||||
|
assert.ok(engine.className.includes("cursor-not-allowed"), "search engine preference retains its own key gate");
|
||||||
|
act(() => { engine.click(); });
|
||||||
|
assert.equal(document.querySelector('[data-testid="search-engine-menu"]'), null, "keyless engine preference does not open");
|
||||||
|
assert.ok(document.querySelector('[data-testid="search-engine-row"]')?.textContent?.includes(reason));
|
||||||
|
} finally {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
for (const [key, descriptor] of previous) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
else delete globals[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, normalizeContext, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||||
|
import { streamSimple as streamCodex } from "@earendil-works/pi-ai/api/openai-codex-responses";
|
||||||
|
import { APP_IDENTITY, piEngineForSpec, type PiEngine } from "../src/agent/models.ts";
|
||||||
|
import { createProberAgent, createTeacherAgent, createPracticeAgent, type AgentBuildOptions } from "../src/agent/teacher-agent.ts";
|
||||||
|
import { runSubagent } from "../src/agent/subagents/pi-run.ts";
|
||||||
|
import { healToolRecord } from "../src/agent/tools/heal.ts";
|
||||||
|
import metadata from "../package.json" with { type: "json" };
|
||||||
|
|
||||||
|
const factories = [
|
||||||
|
(opts: AgentBuildOptions) => createProberAgent({ chatId: opts.chatId, searchAvailable: () => false } as never, opts),
|
||||||
|
(opts: AgentBuildOptions) => createTeacherAgent(undefined, opts),
|
||||||
|
(opts: AgentBuildOptions) => createPracticeAgent({} as never, opts),
|
||||||
|
];
|
||||||
|
|
||||||
|
function recordingEngine() {
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
faux.setResponses([fauxAssistantMessage([fauxToolCall("again", {}, { id: "call_0" })], { stopReason: "toolUse" }), { ...fauxAssistantMessage([fauxText("done")]), durationMs: 42 }]);
|
||||||
|
const requests: SimpleStreamOptions[] = [];
|
||||||
|
const engine: PiEngine = { model: faux.getModel(), streamFn: (model, context, options) => {
|
||||||
|
requests.push(options ?? {});
|
||||||
|
return faux.streamSimple(model, context, options);
|
||||||
|
} };
|
||||||
|
return { engine, requests };
|
||||||
|
}
|
||||||
|
const again = { name: "again", label: "again", description: "Continue", parameters: Type.Object({}), execute: async () => ({ content: [{ type: "text" as const, text: "ok" }], details: {} }) };
|
||||||
|
|
||||||
|
// Whole-process replacement isolates native auth/profile discovery; fetch never
|
||||||
|
// delegates. No real credentials, .env, OAuth accounts, or model inference.
|
||||||
|
async function closed<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
const env = process.env;
|
||||||
|
const fetch = globalThis.fetch;
|
||||||
|
process.env = { NODE_ENV: "test", HOME: "/nonexistent/session-identity", OPENROUTER_API_KEY: "dummy-router", OPENCODE_API_KEY: "dummy-go", OPENAI_API_KEY: "dummy-openai" };
|
||||||
|
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected fetch"); };
|
||||||
|
try { return await run(); } finally { process.env = env; globalThis.fetch = fetch; }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("native app and conversation identity", { concurrency: false }, () => {
|
||||||
|
it("shares one deeply frozen metadata-derived identity, without mutable conversation state", () => {
|
||||||
|
assert.deepEqual(APP_IDENTITY, { userAgent: { custom: `learnharness/${metadata.version}` }, attribution: { name: metadata.name, url: metadata.homepage } });
|
||||||
|
for (const value of [APP_IDENTITY, APP_IDENTITY.userAgent, APP_IDENTITY.attribution]) assert.ok(Object.isFrozen(value));
|
||||||
|
assert.ok(!Object.hasOwn(APP_IDENTITY, "sessionId"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconstructed prober/teacher/practice Agents reuse the exact chat id across tool rounds", async () => {
|
||||||
|
for (const chatId of ["chat_exact_native_123", "chat_independent_456"]) {
|
||||||
|
for (const factory of factories) {
|
||||||
|
const { engine, requests } = recordingEngine();
|
||||||
|
const agent = factory({ engine, chatId, tools: { again } });
|
||||||
|
assert.equal(agent.identity, APP_IDENTITY);
|
||||||
|
assert.equal(agent.sessionId, chatId);
|
||||||
|
await agent.prompt("offline");
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
for (const options of requests) {
|
||||||
|
assert.equal(options.sessionId, chatId);
|
||||||
|
assert.deepEqual(options.identity, APP_IDENTITY);
|
||||||
|
assert.ok(options.signal instanceof AbortSignal);
|
||||||
|
assert.equal(options.apiKey, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separate extractor/auditor/compaction invocations own distinct stable unsaved conversations", async () => {
|
||||||
|
const ids = new Set<unknown>();
|
||||||
|
// Compaction uses the shared runner with the researcher role.
|
||||||
|
for (const role of ["extractor", "auditor", "researcher"] as const) {
|
||||||
|
const { engine, requests } = recordingEngine();
|
||||||
|
let usage = 0;
|
||||||
|
const result = await runSubagent([again], { role, engine, chatId: "chat_shared_debug_only", systemPrompt: "offline", prompt: "offline", maxTurns: 3, onUsage: () => usage++ });
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
assert.equal(usage, 2);
|
||||||
|
const id = requests[0]!.sessionId;
|
||||||
|
assert.match(id ?? "", /^[0-9a-f-]{36}$/);
|
||||||
|
assert.notEqual(id, "chat_shared_debug_only");
|
||||||
|
assert.ok(!ids.has(id));
|
||||||
|
ids.add(id);
|
||||||
|
for (const options of requests) { assert.equal(options.sessionId, id); assert.deepEqual(options.identity, APP_IDENTITY); }
|
||||||
|
assert.ok(result.newMessages.some((m) => m.role === "toolResult" && typeof m.durationMs === "number"), "new native tool duration fields are preserved");
|
||||||
|
assert.ok(result.newMessages.some((m) => m.role === "assistant" && typeof m.durationMs === "number"), "new native assistant duration fields are preserved");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [provider, id, endpoint] of [
|
||||||
|
["opencode-go", "claude-haiku-5-5", "/inference/go/v1/messages"],
|
||||||
|
["opencode-go", "gemini-4-argon-contributor", "/inference/go/v1/models/gemini-4-argon-contributor:streamGenerateContent"],
|
||||||
|
["opencode-go", "glm-5.3", "/inference/go/v1/chat/completions"],
|
||||||
|
["opencode-go", "gpt-6-luna", "/inference/go/v1/responses"],
|
||||||
|
["openrouter", "openrouter/free", "/api/v1/chat/completions"],
|
||||||
|
] as const) {
|
||||||
|
it(`complete native ${provider}/${id} foreground serialization carries app identity and exact sessions`, async () => closed(async () => {
|
||||||
|
const engine = piEngineForSpec({ provider, id, thinkingLevel: "off" });
|
||||||
|
const requests: Request[] = [];
|
||||||
|
globalThis.fetch = async (input, init) => { const request = new Request(input, init); requests.push(request); await request.text(); return new Response('{"error":{"message":"closed fixture"}}', { status: 400, headers: { "content-type": "application/json" } }); };
|
||||||
|
for (const factory of factories) await factory({ engine, chatId: "chat_wire_exact", tools: {} }).prompt("offline");
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
for (const request of requests) {
|
||||||
|
assert.equal(new URL(request.url).pathname, endpoint);
|
||||||
|
assert.equal(request.headers.get("user-agent"), `learnharness/${metadata.version}`);
|
||||||
|
if (provider === "opencode-go") {
|
||||||
|
assert.equal(new URL(request.url).origin, "https://opencode.ai");
|
||||||
|
assert.equal(request.headers.get("x-opencode-client"), metadata.name);
|
||||||
|
assert.equal(request.headers.get("x-opencode-session"), "chat_wire_exact");
|
||||||
|
assert.ok(!JSON.stringify(Object.fromEntries(request.headers)).includes("dummy-router"));
|
||||||
|
} else {
|
||||||
|
assert.equal(new URL(request.url).origin, "https://openrouter.ai");
|
||||||
|
assert.equal(request.headers.get("x-openrouter-title"), metadata.name);
|
||||||
|
assert.equal(request.headers.get("http-referer"), metadata.homepage);
|
||||||
|
assert.equal(request.headers.get("x-session-id"), "chat_wire_exact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("native OpenAI cache keys clamp to 64 characters while affinity retains exact session", async () => closed(async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "openai", id: "gpt-4o-mini", thinkingLevel: "off" });
|
||||||
|
const sessionId = "chat_" + "a".repeat(70);
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
let headers = new Headers();
|
||||||
|
globalThis.fetch = async (input, init) => { const request = new Request(input, init); headers = request.headers; body = JSON.parse(await request.text()); return new Response("{}", { status: 400, headers: { "content-type": "application/json" } }); };
|
||||||
|
await createTeacherAgent(undefined, { engine, chatId: sessionId, tools: {} }).prompt("offline");
|
||||||
|
assert.equal(body.prompt_cache_key, Array.from(sessionId).slice(0, 64).join(""));
|
||||||
|
assert.equal(headers.get("session_id"), sessionId);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("native OpenAI cache-key clamp counts Unicode code points, not UTF-16 units", async () => closed(async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "openai", id: "gpt-4o-mini", thinkingLevel: "off" });
|
||||||
|
const sessionId = "chat_" + "😀".repeat(70);
|
||||||
|
let payload: unknown;
|
||||||
|
// HTTP affinity headers cannot encode emoji. Observe native payload
|
||||||
|
// serialization before transport, then stop deliberately (no accounts).
|
||||||
|
await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }] }), {
|
||||||
|
sessionId,
|
||||||
|
onPayload: (value) => { payload = value; throw new Error("CLOSED: payload captured"); },
|
||||||
|
}).result();
|
||||||
|
assert.equal((payload as { prompt_cache_key: string }).prompt_cache_key, Array.from(sessionId).slice(0, 64).join(""));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("native Codex serialization shares the 64-code-point clamp without an account or OAuth flow", async () => closed(async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "openai-codex", id: "gpt-6-luna", thinkingLevel: "off" });
|
||||||
|
// Synthetic, unsigned fixture token solely satisfies native local claim
|
||||||
|
// decoding. Stop at payload capture: no account, OAuth, or fetch.
|
||||||
|
const claims = { "https://api.openai.com/auth": { chatgpt_account_id: "offline-fixture" } };
|
||||||
|
const apiKey = `dummy.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.dummy`;
|
||||||
|
for (const sessionId of ["chat_" + "a".repeat(70), "chat_" + "😀".repeat(70)]) {
|
||||||
|
let payload: unknown;
|
||||||
|
// The app intentionally has no Codex auth row: exercise the native
|
||||||
|
// serializer directly rather than adding an OAuth configuration path.
|
||||||
|
assert.equal(engine.model.api, "openai-codex-responses");
|
||||||
|
const result = await streamCodex(engine.model as Model<"openai-codex-responses">, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }] }), {
|
||||||
|
apiKey, sessionId, identity: APP_IDENTITY,
|
||||||
|
onPayload: (value) => { payload = value; throw new Error("CLOSED: payload captured"); },
|
||||||
|
}).result();
|
||||||
|
assert.ok(payload, result.errorMessage);
|
||||||
|
assert.equal((payload as { prompt_cache_key: string }).prompt_cache_key, Array.from(sessionId).slice(0, 64).join(""));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("SDK native header precedence and cache-off behavior remain untouched", async () => closed(async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "opencode-go", id: "gpt-6-luna", thinkingLevel: "off" });
|
||||||
|
let headers = new Headers();
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
globalThis.fetch = async (input, init) => { const request = new Request(input, init); headers = request.headers; body = JSON.parse(await request.text()); return new Response("{}", { status: 400, headers: { "content-type": "application/json" } }); };
|
||||||
|
await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "offline", timestamp: 1 }] }), { sessionId: "chat_cache_off", cacheRetention: "none", headers: { "user-agent": "explicit-sdk-override", "x-opencode-client": null } }).result();
|
||||||
|
assert.equal(headers.get("user-agent"), "explicit-sdk-override");
|
||||||
|
assert.equal(headers.get("x-opencode-client"), null);
|
||||||
|
// Native OpenCode routing identity remains even with cache retention off.
|
||||||
|
assert.equal(headers.get("x-opencode-session"), "chat_cache_off");
|
||||||
|
assert.equal(body.prompt_cache_key, undefined);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("completed malformed native JSON stays rejected despite app prepareArguments healing", async () => closed(async () => {
|
||||||
|
const engine = piEngineForSpec({ provider: "openrouter", id: "openai/gpt-4o-mini", thinkingLevel: "off" });
|
||||||
|
const malformed = '{"value":';
|
||||||
|
globalThis.fetch = async () => new Response(`data: ${JSON.stringify({ id: "offline", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_bad", type: "function", function: { name: "check", arguments: malformed } }] }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } });
|
||||||
|
let executed = 0;
|
||||||
|
let prepared = 0;
|
||||||
|
const tools = healToolRecord({ check: { name: "check", label: "check", description: "check", parameters: Type.Object({ value: Type.Optional(Type.String()) }), execute: async () => { executed++; return { content: [], details: {} }; } } });
|
||||||
|
const heal = tools.check!.prepareArguments!;
|
||||||
|
tools.check!.prepareArguments = (args) => { prepared++; return heal(args); };
|
||||||
|
const result = await runSubagent(Object.values(tools), { role: "extractor", engine, systemPrompt: "offline", prompt: "offline", maxTurns: 1 });
|
||||||
|
const assistant = result.newMessages.find((m) => m.role === "assistant");
|
||||||
|
assert.ok(assistant?.role === "assistant");
|
||||||
|
const call = assistant.content.find((c) => c.type === "toolCall");
|
||||||
|
assert.ok(call?.type === "toolCall" && call.argumentsError);
|
||||||
|
assert.equal(executed, 0);
|
||||||
|
assert.equal(prepared, 1, "SDK prepares arguments but preserves argumentsError for native validation");
|
||||||
|
assert.ok(result.newMessages.some((m) => m.role === "toolResult" && m.isError));
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
composeSlotModel,
|
composeSlotModel,
|
||||||
composeTool,
|
composeTool,
|
||||||
composeQuizMechanics,
|
composeQuizMechanics,
|
||||||
researchToolUnavailable,
|
|
||||||
sectionedSegments,
|
sectionedSegments,
|
||||||
} from "../components/settings/agent-section.tsx";
|
} from "../components/settings/agent-section.tsx";
|
||||||
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
|
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
|
||||||
@@ -328,21 +327,6 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("researchToolUnavailable — the row grays on the registry's own signal", () => {
|
|
||||||
it("research grays exactly when the live search status says unavailable", () => {
|
|
||||||
// the SAME /api/search signal chat-handler's searchAvailable uses —
|
|
||||||
// the row and the registry can never disagree (Teacher flag 2026-09-22)
|
|
||||||
assert.equal(researchToolUnavailable("research", { available: false }), true);
|
|
||||||
assert.equal(researchToolUnavailable("research", { available: true }), false);
|
|
||||||
// loading/failed fetch grays nothing (never a false-negative row)
|
|
||||||
assert.equal(researchToolUnavailable("research", null), false);
|
|
||||||
// only research — the other tools never touch the search port
|
|
||||||
for (const id of ["demo", "visual", "pdf"]) {
|
|
||||||
assert.equal(researchToolUnavailable(id, { available: false }), false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
|
describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
|
||||||
// The server contract: BOTH per-agent lists include the shared
|
// The server contract: BOTH per-agent lists include the shared
|
||||||
// segments (same paths) — the view must list them ONCE.
|
// segments (same paths) — the view must list them ONCE.
|
||||||
|
|||||||
+3
-3
@@ -51,11 +51,11 @@ test("callCost: glm-5.3-flash math — fresh input, cached read, output", () =>
|
|||||||
test("callCost: cache writes bill at the cacheWrite rate", () => {
|
test("callCost: cache writes bill at the cacheWrite rate", () => {
|
||||||
const cost = callCost(
|
const cost = callCost(
|
||||||
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
|
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
|
||||||
"openrouter:qwen/qwen3.8-max-0902",
|
"anthropic:claude-haiku-4-5",
|
||||||
);
|
);
|
||||||
assert.ok(cost !== null);
|
assert.ok(cost !== null);
|
||||||
// fresh 1M @ $2/M + cache write 500k @ $2.5/M
|
// Native 1.4 catalog: fresh 1M @ $1/M + cache write 500k @ $1.25/M.
|
||||||
assert.ok(Math.abs(cost! - (2 + 1.25)) < 1e-9);
|
assert.ok(Math.abs(cost! - (1 + 0.625)) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("callCost: unknown model and absent fields are null, never zero", () => {
|
test("callCost: unknown model and absent fields are null, never zero", () => {
|
||||||
|
|||||||
+124
-31
@@ -1,20 +1,14 @@
|
|||||||
/**
|
/** Keyless research availability + the injected search port.
|
||||||
* Search rewiring (search port wave, 2026-09-20) — the web_search tool's
|
|
||||||
* port injection + the research dispatch tool's registry-level drop.
|
|
||||||
*
|
|
||||||
* Google CSE is dead: the tool runs on the injected search port
|
|
||||||
* (core/search.ts), the runner threads the seam (searchProvider — covered
|
|
||||||
* end-to-end in jobs.test.ts), and jobTools drops the research tool when
|
|
||||||
* ctx.searchAvailable reports no live engine (owner decision: registry
|
|
||||||
* drop, no doomed dispatch, no silent fallback).
|
|
||||||
*
|
|
||||||
* No network, no shared data files — the jobStore fake below is in-memory.
|
* No network, no shared data files — the jobStore fake below is in-memory.
|
||||||
|
* A missing search key is a recoverable tool result, not a dispatch gate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createWebTools } from "../src/agent/tools/web.ts";
|
import { createWebTools } from "../src/agent/tools/web.ts";
|
||||||
import { jobTools } from "../src/agent/tools/jobs.ts";
|
import { jobTools } from "../src/agent/tools/jobs.ts";
|
||||||
|
import { runResearcherAgent } from "../src/agent/subagents/researcher.ts";
|
||||||
|
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import type { JobStore } from "../src/core/ports.ts";
|
import type { JobStore } from "../src/core/ports.ts";
|
||||||
import type { JobRecord } from "../src/core/contracts.ts";
|
import type { JobRecord } from "../src/core/contracts.ts";
|
||||||
import type { SearchHit, SearchProvider } from "../src/core/search.ts";
|
import type { SearchHit, SearchProvider } from "../src/core/search.ts";
|
||||||
@@ -31,9 +25,13 @@ function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/",
|
|||||||
describe("web_search on the injected port (web.ts)", () => {
|
describe("web_search on the injected port (web.ts)", () => {
|
||||||
it("null provider → the honest unavailability string, never a crash", async () => {
|
it("null provider → the honest unavailability string, never a crash", async () => {
|
||||||
const { web_search } = createWebTools(() => null);
|
const { web_search } = createWebTools(() => null);
|
||||||
|
assert.ok(web_search, "web_search is still exposed without a provider");
|
||||||
const out = await web_search!.execute!("c1", { query: "anything" });
|
const out = await web_search!.execute!("c1", { query: "anything" });
|
||||||
assert.match(String(out.details), /not available: no search engine is configured/);
|
const missing = "web_search is not available: no search engine is configured. " +
|
||||||
assert.match(String(out.details), /Tavily or Brave/);
|
"Set a Tavily or Brave search API key in settings. Report in your brief that web search is unavailable.";
|
||||||
|
assert.equal(out.details, missing);
|
||||||
|
assert.deepEqual(out.content, [{ type: "text", text: missing }]);
|
||||||
|
assert.notEqual(out.isError, true, "missing keys remain ordinary recoverable text, not a native error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a live provider returns its hits (count defaults to 5)", async () => {
|
it("a live provider returns its hits (count defaults to 5)", async () => {
|
||||||
@@ -110,11 +108,86 @@ describe("web_search on the injected port (web.ts)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Minimal in-memory JobStore — the drop tests never touch the fs. */
|
describe("keyless researcher loop", () => {
|
||||||
|
it("sends the exact approved missing-credentials guidance in the researcher system prompt", async () => {
|
||||||
|
const approved = "If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources.";
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
faux.setResponses([(context) => {
|
||||||
|
const system = context.messages.find((m) => m.role === "system");
|
||||||
|
assert.ok(system && system.role === "system");
|
||||||
|
assert.equal(typeof system.content, "string");
|
||||||
|
const prompt = system.content as string;
|
||||||
|
assert.ok(prompt.split("\n\n").includes(approved), "approved paragraph is passed verbatim");
|
||||||
|
assert.match(prompt, /Break the question into 2-4 searchable facets/);
|
||||||
|
assert.match(prompt, /## Gaps/);
|
||||||
|
assert.ok(system.toolsAdded?.some((t) => t.name === "web_search"));
|
||||||
|
assert.ok(system.toolsAdded?.some((t) => t.name === "web_fetch"));
|
||||||
|
return fauxAssistantMessage([fauxText("Prompt verified; no model behavior inferred.")]);
|
||||||
|
}]);
|
||||||
|
assert.equal(await runResearcherAgent("Check the prompt only", {
|
||||||
|
engine: { model: faux.getModel(), streamFn: faux.streamSimple },
|
||||||
|
}), "Prompt verified; no model behavior inferred.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// These scripts prove tool/loop mechanics, not real-model compliance.
|
||||||
|
for (const searchFirst of [true, false]) {
|
||||||
|
it(searchFirst ? "recovers from missing search keys by fetching a supplied URL" : "fetch-only research needs no search credentials", async () => {
|
||||||
|
const url = "https://example.com/supplied-source";
|
||||||
|
const brief = `## Summary\nVerified supplied source. [Source](${url})`;
|
||||||
|
const steps: FauxResponseStep[] = [];
|
||||||
|
const fetchCall = () => fauxAssistantMessage(
|
||||||
|
[fauxToolCall("web_fetch", { url }, { id: "fetch1" })], { stopReason: "toolUse" },
|
||||||
|
);
|
||||||
|
if (searchFirst) {
|
||||||
|
steps.push(
|
||||||
|
() => {
|
||||||
|
return fauxAssistantMessage(
|
||||||
|
[fauxToolCall("web_search", { query: "supplied source" }, { id: "search1" })], { stopReason: "toolUse" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(context) => {
|
||||||
|
const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "search1");
|
||||||
|
assert.ok(result && result.role === "toolResult");
|
||||||
|
assert.equal(result.isError, false, "missing keys do not abort the native loop");
|
||||||
|
assert.match(JSON.stringify(result.content), /no search engine is configured/);
|
||||||
|
return fetchCall();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else steps.push(fetchCall());
|
||||||
|
steps.push((context) => {
|
||||||
|
const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "fetch1");
|
||||||
|
assert.ok(result && result.role === "toolResult");
|
||||||
|
assert.equal(result.isError, false);
|
||||||
|
assert.match(JSON.stringify(result.content), /Synthetic verified source content/);
|
||||||
|
return fauxAssistantMessage([fauxText(brief)]);
|
||||||
|
});
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
faux.setResponses(steps);
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
const fetched: string[] = [];
|
||||||
|
globalThis.fetch = (async (input) => {
|
||||||
|
fetched.push(String(input));
|
||||||
|
assert.equal(String(input), url, "only the supplied URL is fetched");
|
||||||
|
return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
assert.equal(await runResearcherAgent(`Verify ${url}`, {
|
||||||
|
engine: { model: faux.getModel(), streamFn: faux.streamSimple },
|
||||||
|
// No search seam: this is the keyless backstop, not a search stub.
|
||||||
|
}), brief);
|
||||||
|
assert.deepEqual(fetched, [url]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Minimal in-memory JobStore — registry tests never touch the fs. */
|
||||||
function fakeJobStore(): JobStore {
|
function fakeJobStore(): JobStore {
|
||||||
const jobs = new Map<string, JobRecord>();
|
const jobs = new Map<string, JobRecord>();
|
||||||
return {
|
return {
|
||||||
createJob(init) {
|
createJob(init) {
|
||||||
const job: JobRecord = {
|
const job: JobRecord = {
|
||||||
id: `job_${jobs.size + 1}`,
|
id: `job_${jobs.size + 1}`,
|
||||||
kind: init.kind,
|
kind: init.kind,
|
||||||
@@ -149,13 +222,28 @@ function fakeCtx(available?: () => boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("the research registry drop (jobTools)", () => {
|
describe("keyless research dispatch (jobTools)", () => {
|
||||||
it("searchUnavailable: false → research drops from the registry (the other job tools stay)", () => {
|
it("searchAvailable: false → research remains in the registry", () => {
|
||||||
const tools = jobTools(fakeCtx(() => false));
|
const tools = jobTools(fakeCtx(() => false));
|
||||||
assert.equal("research" in tools, false, "no research tool — no doomed dispatch");
|
assert.ok(tools.research, "research can fetch supplied URLs without a search provider");
|
||||||
assert.ok(tools.demo && tools.visual && tools.pdf, "the search-less server keeps its other sub-agents");
|
assert.ok(tools.demo && tools.visual && tools.pdf, "the search-less server keeps its other sub-agents");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keyless dispatch preserves the async job acknowledgement and spawn contract", async () => {
|
||||||
|
const ctx = fakeCtx(() => false);
|
||||||
|
const spawned: Array<{ job: JobRecord; input: unknown }> = [];
|
||||||
|
const tools = jobTools({ ...ctx, spawn: (job, input) => { spawned.push({ job, input }); } });
|
||||||
|
const input = { question: "Verify the supplied URL", context: "https://example.com/source" };
|
||||||
|
const out = await tools.research!.execute!("keyless-call", input);
|
||||||
|
assert.match(String(out.details), /^Dispatched \(job job_1\)\./);
|
||||||
|
assert.match(String(out.details), /fire-and-forget/);
|
||||||
|
assert.equal(spawned.length, 1);
|
||||||
|
assert.equal(spawned[0]!.job.kind, "research");
|
||||||
|
assert.equal(spawned[0]!.job.toolCallId, "keyless-call");
|
||||||
|
assert.equal(spawned[0]!.job.status, "running");
|
||||||
|
assert.deepEqual(spawned[0]!.input, input);
|
||||||
|
});
|
||||||
|
|
||||||
it("searchAvailable: true → research present", () => {
|
it("searchAvailable: true → research present", () => {
|
||||||
const tools = jobTools(fakeCtx(() => true));
|
const tools = jobTools(fakeCtx(() => true));
|
||||||
assert.ok(tools.research, "a keyed server exposes research");
|
assert.ok(tools.research, "a keyed server exposes research");
|
||||||
@@ -167,12 +255,8 @@ describe("the research registry drop (jobTools)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── the threaded gate (fix wave, 2026-09-20) ────────────────────────────────
|
// Exercise live registry factories too: search status must not override
|
||||||
//
|
// the existing per-role/user-toggle policy.
|
||||||
// The fix: createTeacherTools/createProberTools used to DROP searchAvailable
|
|
||||||
// when building the inner JobToolContext, so jobTools' gate always took its
|
|
||||||
// ?? true default even when the live wiring said unavailable. These pins
|
|
||||||
// hold the THREADED path (the registry factories, not jobTools directly).
|
|
||||||
|
|
||||||
import { createProberTools, createTeacherTools } from "../src/agent/tools.ts";
|
import { createProberTools, createTeacherTools } from "../src/agent/tools.ts";
|
||||||
import type { ChatStore } from "../src/core/ports.ts";
|
import type { ChatStore } from "../src/core/ports.ts";
|
||||||
@@ -186,19 +270,19 @@ const fakeChatStore = {
|
|||||||
|
|
||||||
const ctxBase = { chatId: "chat-gate-test", chatStore: fakeChatStore };
|
const ctxBase = { chatId: "chat-gate-test", chatStore: fakeChatStore };
|
||||||
|
|
||||||
describe("the threaded searchAvailable gate (registry factories)", () => {
|
describe("keyless role registries", () => {
|
||||||
it("createTeacherTools: unavailable drops research on the live wiring path", () => {
|
it("createTeacherTools: unavailable keeps research on the live wiring path", () => {
|
||||||
const tools = createTeacherTools({
|
const tools = createTeacherTools({
|
||||||
...ctxBase,
|
...ctxBase,
|
||||||
jobStore: fakeJobStore(),
|
jobStore: fakeJobStore(),
|
||||||
spawn: () => {},
|
spawn: () => {},
|
||||||
searchAvailable: () => false,
|
searchAvailable: () => false,
|
||||||
});
|
});
|
||||||
assert.equal("research" in tools, false, "the threaded signal reached jobTools' gate");
|
assert.ok(tools.research, "no search key is required to dispatch research");
|
||||||
assert.ok(tools.demo && tools.visual && tools.pdf, "the other job tools survive");
|
assert.ok(tools.demo && tools.visual && tools.pdf, "the other job tools survive");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createTeacherTools: available keeps research (the gate reads the signal both ways)", () => {
|
it("createTeacherTools: available keeps research", () => {
|
||||||
const tools = createTeacherTools({
|
const tools = createTeacherTools({
|
||||||
...ctxBase,
|
...ctxBase,
|
||||||
jobStore: fakeJobStore(),
|
jobStore: fakeJobStore(),
|
||||||
@@ -208,7 +292,7 @@ describe("the threaded searchAvailable gate (registry factories)", () => {
|
|||||||
assert.ok(tools.research, "a keyed server exposes research through the teacher");
|
assert.ok(tools.research, "a keyed server exposes research through the teacher");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createProberTools: research + pdf are phase-omnipresent; the gate drops research only", () => {
|
it("createProberTools: research + pdf remain phase-omnipresent with or without search keys", () => {
|
||||||
const on = createProberTools({
|
const on = createProberTools({
|
||||||
...ctxBase,
|
...ctxBase,
|
||||||
deliver: () => {},
|
deliver: () => {},
|
||||||
@@ -225,16 +309,25 @@ describe("the threaded searchAvailable gate (registry factories)", () => {
|
|||||||
spawn: () => {},
|
spawn: () => {},
|
||||||
searchAvailable: () => false,
|
searchAvailable: () => false,
|
||||||
});
|
});
|
||||||
assert.equal("research" in off, false, "no engine = no research dispatch in the prober either");
|
assert.ok(off.research, "keyless research dispatch is available in the prober too");
|
||||||
assert.ok(off.pdf && off.probe_done, "pdf is keyless — it survives; probe_done always-on");
|
assert.ok(off.pdf && off.probe_done, "pdf is keyless — it survives; probe_done always-on");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createProberTools: the user toggle drops research independently of the gate", () => {
|
it("createProberTools: the user toggle still drops research without search keys", () => {
|
||||||
const tools = createProberTools(
|
const tools = createProberTools(
|
||||||
{ ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => true },
|
{ ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false },
|
||||||
{ research: false },
|
{ research: false },
|
||||||
);
|
);
|
||||||
assert.equal("research" in tools, false, "toggle-filtered like the teacher side");
|
assert.equal("research" in tools, false, "toggle-filtered like the teacher side");
|
||||||
assert.ok(tools.pdf, "pdf unaffected by a research toggle");
|
assert.ok(tools.pdf, "pdf unaffected by a research toggle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createTeacherTools: the user toggle still drops research without search keys", () => {
|
||||||
|
const tools = createTeacherTools(
|
||||||
|
{ ...ctxBase, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false },
|
||||||
|
{ research: false },
|
||||||
|
);
|
||||||
|
assert.equal("research" in tools, false);
|
||||||
|
assert.ok(tools.pdf && tools.demo && tools.visual);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
|
|||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
|
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
|
||||||
);
|
);
|
||||||
assert.match(html, /Add a provider key\./);
|
assert.ok(html.indexOf('data-testid="setup-key-provider"') < html.indexOf('data-testid="chatgpt-login"'), "API keys remain primary, ChatGPT is secondary");
|
||||||
|
assert.doesNotMatch(html, /chatgpt-privacy-notice|OpenAI privacy|Manage usage/);
|
||||||
assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next."));
|
assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next."));
|
||||||
assert.doesNotMatch(html, /First, add|keys unlock|—/);
|
assert.doesNotMatch(html, /First, add|keys unlock|—/);
|
||||||
assert.ok(html.includes('data-testid="setup-key-provider"'));
|
assert.ok(html.includes('data-testid="setup-key-provider"'));
|
||||||
|
|||||||
Reference in New Issue
Block a user