Compare commits

...
14 Commits
91 changed files with 4650 additions and 2487 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* GET /api/activity — the dock's one poll: every running background
* process, every view (chat, home, everywhere). Composition only — the
* feed lives in server/observability.ts; cost folds client-side via
* core/prices.ts. See that module's header for why /api/jobs stays
* feed lives in server/observability.ts; costs are projected server-side
* through Pi's catalog and public calculator. See that module's header for why /api/jobs stays
* chat-scoped and untouched.
*/
import { activityFeed } from "@/src/server";
+2 -1
View File
@@ -1,4 +1,4 @@
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
return Response.json({
...chat,
messages: chat.messages,
pricing: sessionCost(chat),
turnInFlight: isTurnRunning(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
+1 -1
View File
@@ -4,7 +4,7 @@
* three usage channels (chat extraction runs, notes-source runs,
* cluster-naming runs). Thin transport over src/server's graphCosts();
* the fold itself is pure core (core/cost-summary.ts). Dollars are
* inferred from PRICES at read time — never stored, so a price sync
* inferred from Pi's catalog/calculator at read time — never stored, so a catalog update
* re-prices the whole history for free.
*
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
+3 -2
View File
@@ -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
@@ -7,5 +7,6 @@ import { modelCatalog } from "@/src/server";
* catalog: no network, stable within a pi-ai version.
*/
export async function GET() {
return Response.json(modelCatalog());
await refreshConnectionCatalog();
return Response.json(modelCatalog(), { headers: { "Cache-Control": "no-store" } });
}
+10 -8
View File
@@ -4,8 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
import { formatTokens, formatUsd } from "@/components/usage-format";
import { isModelsRefusal, isProviderKeyRefusal } from "@/src/core";
import { formatTokens, planCostLabel } from "@/components/usage-format";
import { useServerEvent, useServerResync } from "@/components/server-events";
/**
@@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), fetched when truth moves (events
* channel, S5) and folded client-side with core/prices.ts.
* Cost is INFERRED (jobCost); the context line is the LAST model call's
* Cost is INFERRED server-side through Pi; the context line is the LAST model call's
* input+output against the model's limit (JobUsage.lastStep — pending
* contract) — never a cumulative sum, which would be the wrong number.
* Absent data renders "—", never zero (the doctrine).
@@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable<JobRecord["usage"]> {
}
export interface FeedJob extends JobRecord {
/** Read-time server estimate; absent on older transport payloads. */
costUsd?: number | null;
title: string;
usage?: FeedUsage;
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
@@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
// blob shape's call — here the line simply hides).
const limit = job.contextLimit;
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
// Same metric as the session meter (prices.ts stepFootprint — the
// Same metric as the session meter (four normalized buckets — the
// context-meter audit, owner 2026-09-23): all four directions.
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
// alone reads wildly low on cache-heavy jobs.
@@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
title: job.title,
chatId: job.chatId,
startedAt: Date.parse(job.createdAt) || 0,
cost: jobCost(job),
cost: job.costUsd ?? null,
context,
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup
@@ -348,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
Background agents
{blobs.length > 0 && (
<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>
)}
</div>
@@ -423,7 +425,7 @@ function ActivityBlobRow({
)}
</span>
<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>
</button>
@@ -462,7 +464,7 @@ function ActivityBlobRow({
<div className="flex justify-between gap-2">
<span>Cost</span>
<span className="tabular-nums text-card-foreground">
{blob.cost === null ? "—" : formatUsd(blob.cost)}
{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
</span>
</div>
<div className="flex justify-between gap-2">
+17 -6
View File
@@ -57,7 +57,7 @@ import { PracticeDock } from "@/components/practice-dock";
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
import { TopBar } from "@/components/top-bar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import {
@@ -236,7 +236,7 @@ export function Chat({
// (core/view.ts projectView); sends are ONE explicit input event each
// ({chatId, input} — the stash indirection died with the SDK), and the
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initiallyInFlight });
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
@@ -717,8 +717,9 @@ export function Chat({
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
if (!Array.isArray(record.messages)) return;
setMessages(record.messages);
// The continuation seam (contracts.ts protocol §4): a job result
@@ -845,12 +846,13 @@ export function Chat({
const planTree = useMemo(() => latestTree(messages), [messages]);
// ── Session usage (wave-4 stats bar) ──
// The money channel: the record's turns log, folded client-side by
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
// The money channel: the record's turns log and read-time server Pi
// pricing projection. Seeded once on mount (the page doesn't carry turns),
// refreshed when the local stream settles (each turn appends to the log
// server-side), and kept fresh by syncFromServer whenever it already
// fetches the record. No turns yet → the bar renders "—" everywhere.
const [turns, setTurns] = useState<TurnUsage[]>([]);
const [pricing, setPricing] = useState<SessionPricing | undefined>();
// The per-chat model control state (model-picker writes via PUT; the
// record is the source of truth, lifted here so the stats bar re-renders).
// The dock's reserved tail height (DockOverlay reports it; the spacer at
@@ -864,8 +866,9 @@ export function Chat({
try {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { turns?: TurnUsage[] };
const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
} catch {
// transient blip: stats stay on the last known figures
}
@@ -1604,6 +1607,8 @@ export function Chat({
onReadingThinking={applyReadingThinking}
phase={phases.at(-1) ?? "probe"}
turns={turns}
contextTokens={contextTokens}
pricing={pricing}
onReserve={setDockReserve}
dictationSlot={<DictationBar dictation={dictation} />}
dictationCancel={dictation.cancel}
@@ -1824,6 +1829,8 @@ function DockOverlay({
onReadingThinking,
phase,
turns,
contextTokens,
pricing,
composer,
onSubmitAnswer,
chatId,
@@ -1855,6 +1862,8 @@ function DockOverlay({
phase: Phase3;
/** The session's usage log — the stats bar beneath the composer folds it. */
turns: TurnUsage[];
contextTokens: number | null;
pricing?: SessionPricing;
composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void;
/** The model control's pass-through (the stats bar hosts it). */
@@ -2327,6 +2336,8 @@ function DockOverlay({
morphing shell so it never joins a height animation. */}
<StatsBar
turns={turns}
contextTokens={contextTokens}
pricing={pricing}
dictationSlot={dictationSlot}
chatId={chatId}
/>
+15 -7
View File
@@ -294,9 +294,17 @@ function timeAgo(startedAt: number): string {
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
* 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)}`;
}
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") : priced ? `$${dollars.toFixed(2)}` : "—";
}
/**
* The cost figures the UI DISPLAYS (owner refinement round, 2026-09-24):
@@ -349,7 +357,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
${displayCostTotals(costs).totalDollars.toFixed(2)}
{graphUsageLabel(costs)}
</button>
</Hint>
{open && (
@@ -378,7 +386,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
];
// The displayed figures come from displayCostTotals (naming excluded —
// the pill and this panel must agree).
const { totalDollars, unpricedRuns } = displayCostTotals(costs);
const { unpricedRuns } = displayCostTotals(costs);
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="mb-2 flex items-baseline justify-between">
@@ -386,7 +394,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
Graph pipeline costs
</span>
<span className="text-foreground tabular-nums">
${totalDollars.toFixed(2)}
{graphUsageLabel(costs)}
{unpricedRuns > 0 && (
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
)}
@@ -397,8 +405,8 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right tabular-nums">
{fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)}
{stage.unpricedRuns > 0 && stage.dollars > 0 && (
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 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">
@@ -422,7 +430,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
</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>
+33 -41
View File
@@ -8,7 +8,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell";
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";
/** 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
* instantly, the fresh one swaps in while the menu is open). */
let catalogCache: ModelCatalogResponse | undefined;
let catalogInflight: Promise<ModelCatalogResponse> | undefined;
function fetchCatalog(force = false): Promise<ModelCatalogResponse> {
if (force) {
catalogCache = undefined;
catalogInflight = undefined;
}
catalogInflight ??= fetch("/api/models")
.then((r) => {
if (!r.ok) throw new Error(`catalog request failed: ${r.status}`);
return r.json() as Promise<ModelCatalogResponse>;
})
.then((c) => {
catalogCache = c;
return c;
})
.catch((err) => {
catalogInflight = undefined; // retry on next mount/open
throw err;
});
return catalogInflight;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogInflight = fetch("/api/models")
.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(); } })
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
const [catalog, setCatalog] = useState(catalogCache);
useEffect(() => {
if (catalogCache) return;
let alive = true;
fetchCatalog()
.then((c) => alive && setCatalog(c))
.catch(() => {
/* the picker stays closed on failure; the buttons still
display the strong slot from the record fetch */
});
return () => {
alive = false;
};
}, []);
const refresh = useCallback(() => {
fetchCatalog(true)
.then(setCatalog)
.catch(() => {
/* refresh is best-effort: the cached list stays on failure */
const first = catalogReaders.size === 0;
const read = () => setCatalog(catalogCache);
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
publishCatalog(); fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { catalog, refresh };
}
@@ -92,7 +81,7 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
* changing the new-session default. Unpicked reads "—" (the
* no-factory-fallback language: the UI never presents a default model).
*/
export function ModelControl({ chatId }: { chatId?: string }) {
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>();
@@ -147,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) {
: strong;
const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider;
useEffect(() => {
onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined);
}, [effectiveProvider, effectiveId, onModelChange]);
const storedLevel = override?.thinkingLevel;
const entry = catalog?.models.find(
(m) => m.id === effectiveId && m.provider === effectiveProvider,
+11
View File
@@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
measuredContextTokens,
newAnswerResult,
newNextMessage,
newProblemDismissedMarker,
@@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
export interface SessionState {
messages: LhViewMessage[];
nativeMessages: LhMessage[];
/** Completed conversation usage; null after compaction or before first usage. */
contextTokens: number | null;
status: SessionStatus;
error: { message: string } | null;
/** Steers sent but not yet drained (composer steering, 2026-10-03):
@@ -220,6 +223,7 @@ export class SessionModel {
// The live turn's execution layer (view model mid-turn states):
private executions = new Map<string, LiveExecution>();
private streamingIds = new Set<string>();
private incompleteUsageIds = new Set<string>();
/** The in-flight assistant message's client id (see applyWireEvent). */
private streamingMessageId: string | null = null;
private statusValue: SessionStatus = "ready";
@@ -257,6 +261,7 @@ export class SessionModel {
return {
messages: projectView([...this.base, ...this.tail], live),
nativeMessages: [...this.base, ...this.tail],
contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds),
status: this.statusValue,
error: this.errorValue,
steerQueue: this.steerQueueValue,
@@ -364,6 +369,8 @@ export class SessionModel {
// the wire) still wins.
const id = message.id ?? this.streamingMessageId ?? localId();
const withId = { ...message, id } as LhMessage;
if (agent.type === "message_end") this.incompleteUsageIds.delete(id);
else this.incompleteUsageIds.add(id);
if (agent.type === "message_start") {
this.streamingMessageId = id;
this.streamingIds.add(id);
@@ -487,6 +494,7 @@ export class SessionModel {
// every path that actually replays onto a stale tail).
if (!refetchRecordFirst) {
this.mutate(() => {
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null;
});
@@ -502,6 +510,7 @@ export class SessionModel {
if (Array.isArray(record.messages)) {
this.mutate(() => {
this.base = record.messages!;
this.incompleteUsageIds.clear();
this.tail = [];
});
}
@@ -509,6 +518,7 @@ export class SessionModel {
// The record did not land — restore the clean-slate guard
// before the replay (no atomic swap is coming).
this.mutate(() => {
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null;
});
@@ -714,6 +724,7 @@ export class SessionModel {
setMessages(record: LhMessage[]): void {
this.mutate(() => {
this.base = record;
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null; // the tail (and its slot) is gone
});
+1
View File
@@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
return {
messages: state.messages,
nativeMessages: state.nativeMessages,
contextTokens: state.contextTokens,
status: state.status,
error: state.error,
steerQueue: state.steerQueue,
+13 -4
View File
@@ -72,6 +72,7 @@ import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
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
* with the resolved default (owner: the hint ALWAYS names the default). */
@@ -344,18 +345,23 @@ export function AgentSection({
// fetch each per section mount. The models read ALSO re-runs on the
// cross-surface signal (fix wave: another surface's write re-reads
// 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")
.then((r) => r.json())
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((body: ModelsBody) => {
if (!modelsMounted.current || request !== modelsRequest.current) return;
setCatalog(body.models);
setSlots(body.slots);
})
.catch(() => setCatalog(null));
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
}, []);
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
// agent section, but this view (the toggles' truth) never re-read —
// the settings-saved law (every reader re-reads) was missing this
@@ -364,6 +370,7 @@ export function AgentSection({
});
useEffect(() => {
modelsMounted.current = true;
readModels();
fetch("/api/settings/prompts")
.then((r) => r.json())
@@ -380,6 +387,7 @@ export function AgentSection({
setEngineKeys(present);
})
.catch(() => {});
return () => { modelsMounted.current = false; modelsRequest.current++; };
}, []);
// The reason line re-reads /api/search AFTER an engine write lands —
@@ -640,6 +648,7 @@ export function AgentSection({
ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2"
>
{slot !== "strong" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
+380
View File
@@ -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 credentials in an unencrypted local file 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. 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>;
}
+17 -20
View File
@@ -15,8 +15,8 @@
* 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
* stored, otherwise it lives in the Add dropdown like everyone else.
* - Tools & services (Deepgram, the Google pair) keep their own group,
* same look, same interaction.
* - Providers groups API keys, subscriptions, dictation and search.
* Unknown variables stay masked/read-only at the end of Model API keys.
*
* API: GET /api/keys (masked statuses + the providers array), PUT
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
@@ -26,6 +26,7 @@
import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { ChatGPTConnection } from "./chatgpt-connection.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
@@ -412,7 +413,7 @@ export function KeysSection({
return (
<>
<SettingsGroup label="Model providers" testid="group-provider-keys">
<SettingsGroup label="Model API keys" testid="group-provider-keys">
{providerRows.map((status) => (
<KeyTableRow
key={status.id}
@@ -446,10 +447,20 @@ export function KeysSection({
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</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 label="Tools & services" testid="group-tool-keys">
{toolRows.map((status) => (
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
<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
key={status.id}
label={status.info?.label ?? status.id}
@@ -467,22 +478,8 @@ export function KeysSection({
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>
)}
</>
);
}
+1 -1
View File
@@ -126,7 +126,7 @@ export function ModelMenuList({
<span className="flex items-center justify-between gap-2">
<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">
{priceLabel(m.cost)}
{m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
+9 -8
View File
@@ -49,17 +49,18 @@ import { KeysSection } from "./keys-section.tsx";
import { Hint } from "@/components/ui/hint";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx";
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
* prose is out; only the keyboard note stays (storage location is a
* real question users ask). */
const SECTIONS = [
{ id: "general", label: "General" },
{ id: "keys", label: "Keys" },
{ id: "keys", label: "Providers" },
{ id: "agent", label: "Agent" },
{ id: "appearance", label: "Appearance" },
{ id: "graph", label: "Graph" },
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
{ id: "general", label: "General" },
] as const;
type SectionId = (typeof SECTIONS)[number]["id"];
@@ -83,20 +84,20 @@ export function SettingsDialogProvider({
children: React.ReactNode;
}) {
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 open = useCallback((section?: string, row?: string) => {
if (section) setLandOn(section as SectionId);
setLandOn(section ? section as SectionId : "keys");
setLandOnRow(row ?? null);
setShow(true);
}, []);
return (
<DialogContext.Provider value={{ open }}>
<ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
{children}
{show && (
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
)}
</DialogContext.Provider>
</DialogContext.Provider></ChatGPTUIProvider>
);
}
@@ -133,7 +134,7 @@ function SettingsDialog({
* (portals render nothing under renderToStaticMarkup). */
export function SettingsDialogBody({
onClose,
initialSection = "general",
initialSection = "keys",
initialRow = null,
}: {
onClose: () => void;
@@ -376,7 +377,7 @@ export function SettingsDialogBody({
</SettingsSection>
)}
{active === "keys" && (
<SettingsSection title="Keys" testid="section-keys">
<SettingsSection title="Providers" testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection>
)}
+3 -22
View File
@@ -15,12 +15,10 @@
* 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 { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
import { useModelCatalog } from "@/components/model-picker";
import type { ModelCatalogResponse } from "@/src/core";
import { useSettingsSaved } from "@/components/settings-signal";
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
* 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
* leans on). Re-reads on the keys signal. */
export function useZeroKeys(): boolean {
const [zeroKeys, setZeroKeys] = useState(false);
const read = useCallback(() => {
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;
const { catalog } = useModelCatalog();
return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false;
}
/** 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
* re-pinned). Live-updates with the page, never mid-send. */
export function useStrongKeyless(): boolean {
const { catalog, refresh } = useModelCatalog();
useSettingsSaved((section) => {
if (section === "keys" || section === "models") refresh();
});
const { catalog } = useModelCatalog();
return strongKeyless(catalog);
}
+11 -5
View File
@@ -44,6 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
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 { ThinkingMenuList } from "@/components/settings/model-menu";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -147,7 +148,6 @@ export function SetupKeysBody({
}) {
return (
<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">
Choose your AI provider and paste its API key. You’ll pick your models next.
</p>
@@ -211,6 +211,7 @@ export function SetupKeysBody({
</p>
)}
</div>
<div className="mt-2 border-t border-border/60 pt-4"><ChatGPTConnection setup /></div>
</div>
);
}
@@ -265,16 +266,19 @@ export function SetupDialog({
// moment ago (stage 1's own screen) widens the pickers immediately.
useEffect(() => {
if (!open) return;
let cancelled = false;
setBody(null);
setPicked({});
setLevels({});
setError(null);
fetch("/api/keys")
.then((r) => r.json())
.then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? []))
.catch(() => setKeyProviders([]));
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
.catch(() => { if (!cancelled) setKeyProviders([]); });
fetch("/api/models")
.then((r) => r.json())
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((b: ModelsBody) => {
if (cancelled) return;
setBody(b);
setFailed(false);
// Prefill (owner, 2026-09-22): each pinned tier opens with its
@@ -292,7 +296,8 @@ export function SetupDialog({
}
setLevels(preLevels);
})
.catch(() => setFailed(true));
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [open, keysTick]);
// Esc + outside-click — layered dismissal, closable WITHOUT
@@ -472,6 +477,7 @@ export function SetupDialog({
)}
</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="relative flex-1">
<button
+10
View File
@@ -21,6 +21,7 @@ import { fuzzyScore } from "@/src/core";
import { useServerEvent, useServerResync } from "@/components/server-events";
import type { ChatListItem } 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
@@ -47,6 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
*
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
*/
import type { ChatGPTControlBridge } from "@/src/core";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -58,6 +60,7 @@ declare global {
lh?: {
/** Read-only host OS; determines the native-control gutter. */
platform?: string;
chatgpt?: ChatGPTControlBridge;
expandWindow: () => void;
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
@@ -79,6 +82,13 @@ export function useSidebar() {
}
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
// mismatch); the stored value is applied after mount.
const [open, setOpen] = useState(false);
+31 -29
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import type { SessionPricing, TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
const numerator = tokens === null ? "?" : formatTokens(tokens);
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
}
/**
* The below-composer stats bar (wave 4 observability): the session's
* money/context footprint as one quiet row beneath the composer —
@@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
* there died with the injection; a "— · —" summary over an empty
* popover is pure noise) — the dictation strip's middle simply widens.
*
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
* — no cost math lives here. Absent data renders "—", never zero (the
* doctrine): no turns yet → every figure is "—"; turns the price table
* can't price count as unpriced, shown in the popover, never folded into
* a fake $0. The RIGHT side is the model + thinking control (components/
* model-picker.tsx): one controller for the conversation agents, backed by
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
* settings, else config's pin), while the left-side figures still fold the
* turn log's actual billed models.
* Spending arrives already priced by the server through Pi's catalog and
* calculator. Missing cost stays "—", never fake zero. Context is a separate
* completed-conversation measurement, never a money-log/summarizer fold or
* transcript estimate. Its catalog denominator follows the selected model;
* model changes preserve the numerator. Successful compaction clears it to ?.
*/
export function StatsBar({
turns,
contextTokens = null,
pricing,
dictationSlot,
chatId,
leadingSlot,
}: {
turns: TurnUsage[];
contextTokens?: number | null;
pricing?: SessionPricing;
/** The bound session (per-session models, owner 2026-09-28): present
* on the in-session bar — the model control binds THE SESSION'S own
* record; absent on the draft surfaces, where it binds the strong
@@ -49,6 +54,7 @@ export function StatsBar({
leadingSlot?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
// Same dropdown discipline as the model control (owner, df4433f treatment,
// stats round 1): close on any pointerdown outside the bar or on Escape.
const barRef = useRef<HTMLDivElement>(null);
@@ -73,22 +79,17 @@ export function StatsBar({
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// The live-limits join (context-meter amendment, owner 2026-09-23):
// contextWindow's denominator resolves ONLY from pi's live catalog —
// built client-side over the picker's cached /api/models fetch (the
// same fetch ModelControl below rides; module-level dedupe). Catalog
// pending or failed → undefined index → limit null → USAGE-ONLY (the
// honest-render precedent, never a fake window); the usage-only render
// IS the null path.
// Catalog-only denominator; the picker reports its effective selection.
// Missing/pending catalog means usage-only, never a hardcoded maximum.
const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const ctx = contextWindow({ turns }, limits);
const { cost, unpriced } = sessionCost({ turns });
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
const contextLabel = formatContextMeasurement(contextTokens, limit);
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced;
const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
const lastModel = turns.at(-1)?.model;
const pricedCount = turns.length - unpriced - subscription;
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
return (
<div
@@ -110,7 +111,7 @@ export function StatsBar({
{leadingSlot}
{/* The left side: the session-data summary — only once a session
exists (turns). Pre-chat renders nothing (see the header). */}
{turns.length > 0 && (
{(chatId || turns.length > 0) && (
<div ref={barRef} className="relative">
{/* No tooltip — the numbers describe themselves and the popover
speaks on click (owner, subtlety round 2026-09-25). */}
@@ -122,7 +123,7 @@ export function StatsBar({
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
@@ -139,13 +140,14 @@ export function StatsBar({
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
</div>
<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>
</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 && (
<div className="flex justify-between gap-2">
<span>Unpriced turns</span>
@@ -160,7 +162,7 @@ export function StatsBar({
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
const c = pricing?.turnCosts[turn.id] ?? null;
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
@@ -168,7 +170,7 @@ export function StatsBar({
</span>
<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>{c === null ? "—" : formatUsd(c)}</span>
<span>{turn.model.startsWith("chatgpt:") ? "ChatGPT plan" : c === null ? "—" : formatUsd(c)}</span>
</span>
</li>
);
@@ -189,7 +191,7 @@ export function StatsBar({
{/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */}
<ModelControl chatId={chatId} />
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
</div>
);
}
+5
View File
@@ -8,6 +8,11 @@ export function formatUsd(c: number): string {
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". */
export function formatTokens(n: number): string {
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
+84
View File
@@ -0,0 +1,84 @@
# Compaction and usage accounting
## Retention
`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant
message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no
coding-agent dependency. Upgrade this port deliberately; the native golden
fixtures are in `tests/compaction-native-parity.test.ts`.
Walk backward to the approximately 20,000-token retained-tail budget, then select
the nearest valid user/assistant boundary at or after the crossing. Never start
at a tool result. If trailing results alone exceed the budget, keep the preceding
assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts
its visible custom markers to user-like entries, excludes UI-only markers, and
uses the active compaction's retained history rather than discarded messages.
Text, visible thinking, tool-call names/arguments use characters divided by four;
images receive the native fixed 1,200-token allowance. Encrypted signatures are
not measured or stripped. Whole-message boundaries can overshoot. This does
**not** guarantee a measured 20k tail or a fixed post-compaction request size.
Only cutoff selection is ported. LearnHarness retains its educational summary
prompt, plan continuity, persistence, threshold, scheduling, and authorization
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
calls. A cut that drops no active history does not launch a summarizer.
## Conversation meter
The numerator is the latest qualifying completed conversation response's native
Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output;
`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid
counts, and all-zero usage do not replace the last measurement. A successful
compaction marker invalidates earlier measurements: show `? / maximum` until a
new qualifying response completes. Summarizer usage never supplies this meter.
The denominator follows the selected model's catalog limit, independently of the
last numeric measurement. Changing models preserves that numerator. No catalog
limit means no invented denominator; no percentage display is added.
## Spending
`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public
`calculateCost` on a new usage object. The browser receives derived costs, not a
second calculator/rate table. Totals are estimates using the currently shipped
catalog, not provider invoices; catalog updates can reprice history.
Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never
subtract cached reads from it again or migrate old logs as raw inclusive prompt
tokens. New captures preserve the one-hour subset of cache writes and individual
background-job calls. Older write-bearing records with a lost retention split,
and tier-priced multi-call jobs with only aggregate usage, remain unpriced when
the lost detail can change the answer. They are not reconstructed from totals.
Flat-rate historical jobs with sufficient counts remain priceable. Missing models
or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not
API dollars. Summarizer costs continue to count toward session spending.
## Upstream attribution
The cutoff/estimator port adapts MIT-licensed code from
`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package
metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is
`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`.
MIT License
Copyright (c) Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+5 -2
View File
@@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
```
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
@@ -144,7 +143,7 @@ src/
electron/ desktop launcher + security guard (spawns the standalone
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
scripts/ build-server.mjs (packaging guards), import-notes,
backfill-graph, replay-layout, sync-pricing
backfill-graph, replay-layout
data/ chats/ · graph/ (nodes/chains/sessions/coverage
ledgers + positions/sources/render-settings maps) ·
jobs/ · settings.json — runtime state, git-ignored
@@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
## Key mechanisms
See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port,
measurement-only context meter, and server-side current-catalog pricing. There is no
pricing-generation step: Pi AI's catalog and public calculator are the authority.
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
+5
View File
@@ -81,7 +81,12 @@ files:
- node_modules/universalify/**/*
- node_modules/ms/**/*
- node_modules/argparse/**/*
# Main-process ID-token signature verification (no bundled OAuth implementation).
- node_modules/jose/**/*
extraResources:
# Attribution/license for the bounded local Pi cutoff-selector port.
- from: docs/compaction-accounting.md
to: licenses/pi-compaction.md
# Copy from the PARENT dir (.next), not .next/standalone itself:
# electron-builder hard-excludes a copy's root node_modules and the
# default no-filter pattern is a literal path match (empirically both
+43
View File
@@ -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 };
+539
View File
@@ -0,0 +1,539 @@
"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.",
storage: "ChatGPT credentials could not be read or saved. Check local file access, then 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 credential record when its schema is invalid. */
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;
}
/** Private, unencrypted JSON outside learning data. No OS credential-store access. */
function fileStore(file) {
return {
async read() {
try {
return validateState(JSON.parse(await fs.readFile(file, "utf8")));
} catch (err) {
if (err.code === "ENOENT") return null;
throw fail("storage");
}
},
async write(state) {
validateState(state);
const temp = `${file}.${crypto.randomUUID()}.tmp`;
let handle;
try {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
handle = await fs.open(temp, "wx", 0o600);
await handle.writeFile(`${JSON.stringify(state)}\n`);
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;
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; } catch (err) { denyStorage(err); throw err; }
}
function denyStorage(err) {
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
}
async function save(next) {
try { validateState(next); await store.write(next); } 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 && !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 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) {
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, fileStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
+25
View File
@@ -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 };
+43 -2
View File
@@ -25,6 +25,10 @@
*/
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
const { createChatGPT, fileStore } = 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 fs = require("node:fs");
const http = require("node:http");
@@ -128,6 +132,11 @@ if (process.argv.includes("--browser-service")) {
}
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
// 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
// settings-page key change until restart; env wins over 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;
chatgptChild = attachChatGPTChild(child, chatgpt);
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
child.stdout.on("data", fwd);
child.stderr.on("data", fwd);
child.on("exit", (code, signal) => {
log(`server exited (code=${code} signal=${signal})`);
serverProcess = null;
chatgptChild?.dispose();
chatgptChild = null;
chatgpt?.close();
});
resolve(child);
});
@@ -235,6 +250,7 @@ function waitUntilUp(url, tries = 150) {
}
function killServer() {
chatgpt?.close();
if (!serverProcess) return;
const child = serverProcess;
serverProcess = null;
@@ -306,6 +322,8 @@ function createWindow(url) {
minHeight: 600,
});
trustedAppWindows.add(win);
win.once("closed", () => trustedAppWindows.delete(win));
const allowedOrigin = () => new URL(url).origin;
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
// clipboard-sanitized-write — the copy affordances in the transcript
@@ -546,6 +564,7 @@ function installMenu() {
async function main() {
app.on("window-all-closed", () => {
chatgpt?.pause();
// macOS convention: the app stays resident, reactivated from the Dock.
if (process.platform !== "darwin") app.quit();
});
@@ -563,6 +582,20 @@ async function main() {
const devUrl = process.env.LH_DEV_URL;
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
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: fileStore(path.join(app.getPath("userData"), "chatgpt-credentials.json")),
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(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
await startServer(new URL(url).port);
@@ -572,6 +605,14 @@ async function main() {
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();
// 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 });
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); createWindow(url); }
});
createWindow(url);
log(`window open: ${url}`);
+14
View File
@@ -29,6 +29,20 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", {
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"),
onZoom: (cb) => {
const listener = (_event, factor) => cb(factor);
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "learnharness",
"version": "0.3.5-preview.1",
"version": "0.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.3.5-preview.1",
"version": "0.3.6",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.4.0",
@@ -31,6 +31,7 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
"jose": "^6.2.12",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
@@ -11655,6 +11656,15 @@
"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": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.3.5-preview.1",
"version": "0.3.6",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
@@ -46,6 +46,7 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
"jose": "^6.2.12",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
+17
View File
@@ -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*" };
+5
View File
@@ -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 &#226;&#128;&#148; 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

-121
View File
@@ -1,121 +0,0 @@
/**
* Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider
* catalogs (providers/data/*.json).
*
* Usage: npx tsx scripts/sync-pricing.ts
*
* The catalogs are pi-ai's curated, versioned snapshots — the SAME source
* piEngineFor resolves engines from and /api/models serves the picker
* from — so the money channel's lookup table can never disagree with the
* engine. Fully offline (the JSON ships in node_modules); refreshes happen
* by bumping @earendil-works/pi-ai and re-running this script. The tables
* are generated; hand edits are overwritten. A model missing from the
* catalogs gets no entry — cost renders as absent ("—"), never zero.
*
* Key format (model slots wave, 2026-09-20): uniformly provider-qualified
* ("provider:id", incl. "openrouter:<slug>"). Owner decision: no legacy
* compat — records stamped with pre-wave bare slugs simply render unpriced.
*/
import * as fs from "node:fs";
import * as path from "node:path";
const DATA_DIR = path.join(
import.meta.dirname,
"..",
"node_modules",
"@earendil-works",
"pi-ai",
"dist",
"providers",
"data",
);
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
/** One raw catalog entry (fields we consume; the files carry more). */
interface CatalogModel {
id: string;
name: string;
/** pi-ai 1.0 (catalog schema v6): every entry is type-tagged. */
type?: "chat" | "image" | "classifier";
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
/** The pure walk: every catalog entry that carries pricing, keyed
* "provider:id". Exported for the drift-guard test (tests/prices-sync)
* so the guard asserts EXACTLY what this script would generate — the
* test and the generator can never disagree (owner, 2026-09-29). */
export function buildPrices(): Map<string, CatalogModel["cost"]> {
const priced = new Map<string, CatalogModel["cost"]>();
for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) {
const provider = file.slice(0, -".json".length);
const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record<
string,
Record<string, CatalogModel>
>;
for (const models of Object.values(catalog)) {
for (const [id, m] of Object.entries(models)) {
// pi-ai 1.0 (catalog schema v6): keys are type-qualified
// ("chat:<id>", "image:<id>", "classifier:<id>") and entries
// carry their own `type`. We price CHAT models only — our
// usage records are chat — and keys stay "provider:id"
// (owner format, 2026-09-20): strip the chat: prefix, skip the
// rest.
if (m.type && m.type !== "chat") continue;
const plainId = id.startsWith("chat:") ? id.slice("chat:".length) : id;
// A catalog entry without pricing data gets no key at all
// (cost renders "—", never zero).
if (!m.cost) continue;
// First writer wins (an id can appear under two apis within one
// file with identical pricing; cross-file collisions cannot
// happen — keys are provider-qualified).
priced.set(`${provider}:${plainId}`, m.cost);
}
}
}
return priced;
}
function main(): void {
const priced = new Map<string, { cost: CatalogModel["cost"] }>(
[...buildPrices()].map(([k, cost]) => [k, { cost }] as const),
);
// Sort by key for a stable, diff-friendly generated table.
const rows = [...priced.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, m]) => ({
price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
}));
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Keys are uniformly provider-qualified
* ("provider:id") — NO legacy bare-slug compat (owner decision, sync
* script 2026-09-20): pre-wave records stamped with bare slugs render
* unpriced.
* Context-window limits are NOT a table (owner amendment, 2026-09-23):
* every meter's denominator reads pi's LIVE catalog (GET /api/models /
* the activity feed's server-side join) — missing model, usage-only.
* Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts
* (reads pi-ai's shipped catalogs — the same source the engine and the
* picker use). Forgetting the re-run is exactly how models go unpriced
* (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test
* (tests/prices-sync.test.ts) fails the gates until you do. */
export const PRICES: Record<string, ModelPrice> = {
${rows.map((r) => r.price).join("\n")}
};
`;
const src = fs.readFileSync(OUT, "utf-8");
const start = src.indexOf("/** Token prices");
const end = src.indexOf("// ─── Pure helpers");
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
console.log(`prices.ts regenerated from pi-ai's catalogs: ${priced.size} keys priced.`);
}
// Run only as a script, not when imported (the drift-guard test).
if (process.argv[1] && import.meta.filename && path.resolve(process.argv[1]) === import.meta.filename) {
main();
}
+128
View File
@@ -0,0 +1,128 @@
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);
/** Next dev also uses parent/child IPC. IPC alone is not our desktop
* transport: require the same launch markers set by Electron startServer.
* These identify the transport, not a substitute for admission/lease checks. */
function desktopIPCConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
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 (!desktopIPCConnected() || 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 (!desktopIPCConnected()) 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 desktopIPCConnected() && 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 desktopIPCConnected() && 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 (!desktopIPCConnected()) { 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); } };
}
+122
View File
@@ -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 };
}
}
+118
View File
@@ -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;
};
}
+9 -7
View File
@@ -12,6 +12,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
import packageMetadata from "../../package.json" with { type: "json" };
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. */
@@ -93,9 +96,9 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
* 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
* 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 model = models.getModel(spec.provider, spec.id);
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : models.getModel(spec.provider, spec.id);
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
@@ -111,11 +114,10 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
// bound streamSimple: the agent loop forwards state.thinkingLevel as
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
// the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) =>
models.streamSimple(requestModel, context, {
...options,
reasoning: level === "off" ? undefined : level,
}),
streamFn: (requestModel, context, options) => {
const stream = spec.provider === "chatgpt" ? chatGPTStream(typeof background === "function" ? background : () => background, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
},
};
}
+1
View File
@@ -777,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage {
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
// Optional in pi's Usage (providers that never emit reasoning
// tokens leave it unset) — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
+3
View File
@@ -137,6 +137,9 @@ export function browserServiceSpawnEnv(
const out: Record<string, string | undefined> = { ...env };
delete out.ELECTRON_RUN_AS_NODE;
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;
}
+24 -2
View File
@@ -18,7 +18,8 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord } from "./tools/heal.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.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
@@ -97,6 +98,8 @@ export type StepUsageCollector = (usage: Usage) => void;
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
callPurpose?: { background: boolean };
requestCycle?: ChatGPTRequestCycle;
/** The initial transcript, as pi messages (the record IS agent state). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
@@ -178,6 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
// the normal no-observer path (tests, env-off).
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({
identity: APP_IDENTITY,
sessionId: opts.chatId,
@@ -211,13 +215,31 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract).
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) {
agent.subscribe((event: AgentEvent) => {
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// 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);
}
});
+62 -51
View File
@@ -1,7 +1,8 @@
/**
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
* mirroring pi's design (docs aside, this is OUR implementation — pi's
* compaction lives in the coding-agent package we don't depend on).
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
* No coding-agent dependency. See docs/compaction-accounting.md for the
* behavior boundary, native golden fixtures, and upstream MIT notice.
*
* THE CONTRACT: full history stays persisted in the record — extraction
* and the user's transcript keep reading everything. A compaction marker
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
import { newMessageId } from "./native-messages.ts";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
// ─── constants (the tunables, owner-approved) ────────────────────────────────
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
);
}
/** Estimate a message's context tokens from its WIRE-relevant text only
* (pi's estimator): text + thinking + toolCall name/args for assistants,
* content text for user/toolResult/custom. NEVER JSON.stringify of the
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
* whole message — record scaffolding (ids, usage, responseId, app-side
* details, escaped quotes/backslashes) inflates ~3x (measured on the
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
}
} else {
chars += textCharsOfContent(content);
if (message.role === "system") {
const system = message as SystemMessage;
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
}
}
return Math.ceil(chars / 4);
}
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
// measurement of encrypted reasoning or actual provider vision tokens.
const ESTIMATED_IMAGE_CHARS = 4_800;
function textCharsOfContent(content: unknown): number {
if (typeof content === "string") return content.length;
if (!Array.isArray(content)) return 0;
let chars = 0;
for (const block of content as Array<string | { type?: string; text?: string }>) {
if (typeof block === "string") chars += block.length;
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
else if (block?.type === "text") chars += block.text?.length ?? 0;
}
return chars;
}
/** The newest assistant message carrying VALID provider-reported usage
* (pi's rule: skip aborted messages and all-zero usage — they carry no
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
* truth about the wire). The scan STOPS at the newest compaction
* marker: usage captured before a compaction described the pre-cut
* context — the next request carries the summary + kept tail instead,
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
if ((m as { role?: string }).role !== "assistant") continue;
const a = m as AssistantMessage & { stopReason?: string };
if (a.stopReason === "aborted") continue;
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
return i;
}
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
// ─── the cut walk ────────────────────────────────────────────────────────────
/**
* Find the compaction cut index: walk BACKWARDS from the end accumulating
* estimates until ~keepRecentTokens are kept; the cut lands there.
*
* HARD INVARIANT — a tool result is never separated from its call: if the
* walk stops on a toolResult, the KEPT region extends to the assistant
* message carrying its call (keeping the result means keeping the call).
* This is the provider protocol's shape law: dropping a call while
* keeping its result strands an orphan the provider rejects. A PENDING
* question/problem call (no result yet — the pose→answer window) sits at
* the transcript end and is always kept: the walk never crosses it.
*
* Returns 0 when everything must be summarized (the whole history is the
* span — valid), or messages.length when there is nothing meaningful to
* cut (kept budget covers everything — caller checks before compacting).
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
let kept = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (kept >= keepRecentTokens) {
// The cut candidate: extend past orphaned tool results to the
// assistant message carrying their calls.
let cut = i + 1;
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
if (callIndex === -1) break; // no call found (defensive): stop extending
cut = callIndex;
}
return cut;
}
kept += estimateMessageTokens(messages[i]!);
}
return 0; // the whole history fits inside the kept budget — summarize all
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
* markers have no context, just like native metadata entries. */
function cutMessage(message: LhMessage): LhMessage | null {
if (message.role !== "custom") return message;
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
return message;
}
/** The newest assistant message at or before `before` whose content
* carries the tool call `toolCallId` — the call a result answers. */
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
for (let i = before - 1; i >= 0; i--) {
const m = messages[i]!;
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
* AFTER that entry. If oversized trailing results exhaust the budget,
* retain their preceding assistant call, not the entire session.
* No encrypted-signature sizing and no exact retained-token guarantee.
* Returns the first kept record index; a zero/no-progress cut drops nothing.
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
const previous = activeCompactionOf(messages);
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
const start = Math.max(0, previousKept);
const points: number[] = [];
for (let i = start; i < messages.length; i++) {
const message = cutMessage(messages[i]!);
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
}
return -1;
if (!points.length) return start;
let cut = points[0]!;
let accumulated = 0;
for (let i = messages.length - 1; i >= start; i--) {
const message = cutMessage(messages[i]!);
const tokens = message ? estimateMessageTokens(message) : 0;
if (tokens === 0) continue;
accumulated += tokens;
if (accumulated >= keepRecentTokens) {
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
break;
}
}
// Native entries preserve adjacent non-context metadata, but never walk
// backwards across a previous compaction entry.
while (cut > start) {
const before = messages[cut - 1]!;
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
cut--;
}
return cut;
}
// ─── the marker ──────────────────────────────────────────────────────────────
+27
View File
@@ -0,0 +1,27 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { LhMessage } from "./native-messages.ts";
/** Latest completed conversation measurement, never a request-size estimate.
* Ordered compaction markers invalidate every earlier reading. Live callers
* must exclude assistant slots that have not received message_end.
* Reasoning is already inside output; totalTokens is deliberately not used.
*/
export function measuredContextTokens(
messages: readonly LhMessage[],
incompleteIds: ReadonlySet<string> = new Set(),
): number | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role === "custom" && message.customType === "compaction") return null;
if (message.role !== "assistant" || incompleteIds.has(message.id)) continue;
const assistant = message as AssistantMessage;
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
const usage = assistant.usage;
if (!usage) continue;
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
const tokens = buckets.reduce((sum, value) => sum + value, 0);
if (tokens > 0) return tokens;
}
return null;
}
+8 -5
View File
@@ -87,8 +87,8 @@ export interface ChatSettings {
* billed (CallUsage per agent step). Steps, not a sum: cost and context
* folds happen at read time (core/prices.ts). */
export interface ExtractionStageUsage {
/** The model that ran this stage, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
/** The model that ran this stage, provider-qualified ("provider:id")
* for key-independent Pi catalog lookup at read time. */
model: string;
steps: CallUsage[];
}
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
* flattened to the fields we persist (undefined = provider didn't report;
* renderers show absent, never zero). */
export interface CallUsage {
/** Total input (prompt) tokens, cached portion included. */
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
inputTokens: number | undefined;
outputTokens: number | undefined;
/** Cached input tokens READ (billed at the cache-read rate, if any). */
@@ -126,6 +126,9 @@ export interface CallUsage {
/** Input tokens written to the cache this call (OpenRouter bills writes
* for some providers; undefined = not reported/not billed). */
cacheWriteTokens: number | undefined;
/** Subset of cacheWriteTokens written with one-hour retention. Missing
* on older logs; new native captures explicitly record zero if absent. */
cacheWrite1hTokens?: number;
/** The reasoning portion of the output tokens. */
reasoningTokens: number | undefined;
}
@@ -144,8 +147,8 @@ export interface TurnUsage {
* entry to the transcript (the block this turn "belongs" to). */
messageId: string;
at: string;
/** The model that ran the turn, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
/** The model that ran the turn, provider-qualified ("provider:id")
* for key-independent Pi catalog lookup at read time. */
model: string;
/** One entry per agent step (model call) in this turn. */
steps: CallUsage[];
+22
View File
@@ -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;
}
+21 -10
View File
@@ -43,7 +43,7 @@ export interface JobRecord {
* money-channel doctrine; cost renders "—", never $0). */
export interface JobUsage {
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
model: string;
/** Running totals summed over the run's model calls so far. */
inputTokens: number | undefined;
@@ -51,6 +51,10 @@ export interface JobUsage {
cacheReadTokens: number | undefined;
cacheWriteTokens: number | undefined;
reasoningTokens: number | undefined;
cacheWrite1hTokens?: number;
/** Individual calls for current-catalog per-call tier pricing. Absent on
* older jobs; never backfill them from cumulative totals. */
calls?: CallUsage[];
/** How many model calls the run has made (each bills separately). */
steps: number;
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
@@ -67,7 +71,7 @@ export interface JobUsage {
* object with these fields folds, including the real thing and test fakes). */
export interface UsageStepLike {
inputTokens?: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
outputTokens?: number | undefined;
outputTokenDetails?: { reasoningTokens?: number } | undefined;
}
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
output: number;
cacheRead: number;
cacheWrite: number;
cacheWrite1h?: number;
reasoning?: number;
}): UsageStepLike {
return {
inputTokens: usage.input,
outputTokens: usage.output,
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
};
}
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
): JobUsage {
const add = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
const call: CallUsage = {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
};
return {
model,
inputTokens: add(total?.inputTokens, step.inputTokens),
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
steps: (total?.steps ?? 0) + 1,
lastStep: {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
},
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
lastStep: call,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
/** One picker row: everything the UI shows about a model, projected from
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
* units core/prices.ts's generated table carries). */
* units as Pi's server-side pricing calculator). */
export interface ModelCatalogEntry {
id: string;
name: string;
+26 -12
View File
@@ -9,7 +9,7 @@
* the same ruling as wave 7's stages.embedding.)
*
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
* core/prices.ts — never stored anywhere. An unpriced model contributes
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
* "— unpriced" honestly, never a silent zero. Tokens always sum.
*
@@ -18,13 +18,15 @@
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
import { callCost, sumSteps } from "./prices.ts";
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
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). */
dollars: number;
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
unpricedRuns: number;
/** Runs that billed this stage. */
runs: number;
@@ -34,6 +36,7 @@ export interface StageCost {
/** One run in the recent-runs list (extraction or naming). */
export interface RecentRun {
subscriptionRuns?: number;
startedAt: number;
/** ISO wall-clock. */
at: string;
@@ -51,9 +54,10 @@ export interface RecentRun {
/** The cost pill's whole payload: stage totals + recent runs. */
export interface GraphCostSummary {
subscriptionRuns?: number;
/** Inferred USD across all priced stages of all runs. */
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;
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
* the embedding stage is gone with the provider embedder; the
@@ -88,10 +92,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
/** One stage's dollars over its steps; null when the model is unpriced
* (all-or-nothing: a stage runs one model). */
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
if (isChatGPTPlan(model) || steps.length === 0) return null;
let total = 0;
for (const step of steps) {
const c = callCost(step, model);
const c = price(step, model);
if (c === null) return null;
total += c;
}
@@ -101,6 +106,9 @@ function stageDollars(model: string, steps: readonly CallUsage[]): number | null
/** Merge one step-set's tokens into an accumulating CallUsage. */
function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
const summed = sumSteps(steps);
// Fold this before updating total writes, so an older write-bearing
// aggregate with a missing split remains unknown even after fresh data.
into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens;
into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
@@ -110,7 +118,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
/** Fold every usage channel into the pill's payload. Pure: same input,
* same output — no clock, no fs, no store reads. */
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
const extraction = emptyStage();
const audit = emptyStage();
const runs: RecentRun[] = [];
@@ -120,14 +128,16 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
// Per-stage: tokens always count; dollars only when priced.
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
if (extDollars === null) extraction.unpricedRuns++;
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
else if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
if (audDollars === null) audit.unpricedRuns++;
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
else if (audDollars === null) audit.unpricedRuns++;
else audit.dollars += audDollars;
// Run-level: null only when EVERY stage is unpriced.
@@ -135,8 +145,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const runDollars = allStages.every((d) => d === null)
? null
: 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];
runs.push({
...(subscriptionRuns ? { subscriptionRuns } : {}),
startedAt: run.startedAt,
at: run.at,
durationMs: run.durationMs,
@@ -159,8 +171,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const capped = runs.slice(0, input.recentCap ?? 10);
// 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 {
...(subscriptionRuns ? { subscriptionRuns } : {}),
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, audit },
+2
View File
@@ -48,6 +48,8 @@ export * from "./input-events.ts";
export * from "./view.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
export * from "./cost-summary.ts";
export * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
+51
View File
@@ -674,6 +674,57 @@ export function endsInDismissal(messages: readonly LhMessage[]): boolean {
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 {
const last = messages[messages.length - 1];
if (!last || (last as { role?: string }).role !== "toolResult") return false;
+39 -1738
View File
File diff suppressed because it is too large Load Diff
+50 -10
View File
@@ -7,6 +7,10 @@ import {
type TeachingAgent,
} from "../agent/teacher-agent.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 { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
@@ -15,6 +19,7 @@ import {
noteSteerVisible,
reconcileUserSteers,
registerLiveAgent,
restoreUncommittedAutomatic,
setJobRespawn,
setTurnRunner,
settleSteers,
@@ -41,7 +46,7 @@ import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
import { composeInstructions } from "../agent/teacher-agent.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 { settledTurnResponse } from "./turn-runs.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
// path. The affected tier is strong — the conversation chain's own tier
// (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)) {
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
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
// beginTurn, no liveness effects — and the record re-reads after
// 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}`;
await ensureCompacted(
{
chatStore: stores.chatStore,
engine: conversationEngine,
engine: piEngineForSpec(conversation, true),
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
modelId: modelIdForCompaction,
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -287,12 +308,14 @@ export async function handleChatInput(
// ride this same write-ahead - the pump de-queued them at fire, so this
// is the ONLY copy ever appended.)
const merged = [...record.messages, ...appends];
const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
stores.chatStore.saveMessages(chatId, merged);
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
// in the record — flip their jobs now (the wake's steers ride this
// 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);
}
} catch (err) {
@@ -310,6 +333,7 @@ export async function handleChatInput(
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
// Optional in pi's Usage — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
});
@@ -339,8 +363,19 @@ export async function handleChatInput(
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// 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 = {
engine,
callPurpose,
requestCycle,
messages: asAgentMessages(merged),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
@@ -439,6 +474,7 @@ export async function handleChatInput(
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
const handle = runPiTurn({
agent: constructed,
requestCycle,
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
// default in ui-stream.
@@ -454,13 +490,14 @@ export async function handleChatInput(
// route through here: they ride the error-result path and reach the
// transcript directly (the retry context the model saw).
onError: (error) => {
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onFinish: (turnMessages, isAborted) => {
// Wave B: the record write is the agent's own final state —
// 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
// refolds this on the client after the turn settles. The
// provenance message is the turn's FIRST new assistant message.
@@ -479,7 +516,7 @@ export async function handleChatInput(
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
// no extraction, no graph, no coverage (v1: the session itself
// 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
// (extraction-UX redesign, 2026-09-24): the trigger compares
// against it and the pipeline's batches share it.
@@ -520,18 +557,20 @@ export async function handleChatInput(
// beneath-threshold record is a silent no-op. Skipped under
// tests (the extraction precedent — scripted chats are
// fixtures; the service itself is pinned directly).
if (process.env.NODE_ENV !== "test")
if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
kickCompaction(() => {
const fresh = effectiveSettings();
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
if (!spec) return null;
const kickEngine = piEngineForSpec(spec);
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
let kickEngine: PiEngine;
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
const freshRecord = stores.chatStore.getChat(chatId);
if (!freshRecord) return null;
return {
chatStore: stores.chatStore,
engine: kickEngine,
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
modelId: kickModelId,
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -558,6 +597,7 @@ export async function handleChatInput(
// 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
// 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 });
} catch (err) {
// The stream never started, so no onFinish will fire — clear the registry
+6 -2
View File
@@ -89,6 +89,8 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
/** Everything the service needs from its host (chat-handler wires the
* production versions; tests inject). */
export interface CompactionDeps {
/** Eligibility only for NEW runs; structural waits still apply. */
canStart?: () => boolean;
chatStore: ChatStore;
/** The chat's conversation engine — also the summarizer's. */
engine: PiEngine;
@@ -173,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
const cut = compactionCutIndex(messages);
if (cut >= messages.length) return; // nothing to drop (defensive)
if (cut <= 0 || cut >= messages.length) return; // no history can be dropped
// The previous compaction's kept boundary (chained compactions
// summarize the old summary + what rode after it).
@@ -238,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
reasoningTokens: usage.reasoning ?? 0,
},
],
@@ -267,6 +270,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
// injected summarizer.
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
await awaitCompaction(chatId);
if (deps.canStart?.() === false) return;
const record = deps.chatStore.getChat(chatId);
if (!record) return;
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
@@ -291,7 +295,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
if (state().inFlight.has(chatId)) return;
const deps = buildDeps();
if (!deps) return;
if (!deps || deps.canStart?.() === false) return;
const record = deps.chatStore.getChat(chatId);
if (!record) return;
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
+17
View File
@@ -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);
}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "../core/graph-deletion.ts";
import { extractionStatuses } from "../agent/subagents/extraction.ts";
import { stores } from "./stores.ts";
import { callCost } from "./pricing.ts";
/**
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
@@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary {
.readSources()
.filter((s) => s.extractions?.length)
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
return graphCostSummary({ chats, sources });
return graphCostSummary({ chats, sources }, callCost);
}
/** The graph's version stamp (session-deletion wave 6): the max mtime
+1
View File
@@ -43,3 +43,4 @@ export * from "./compaction.ts";
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";
export { sessionCost } from "./pricing.ts";
+66 -5
View File
@@ -66,7 +66,9 @@ import {
import type { TurnInput } from "../core/input-events.ts";
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.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 type { Agent } from "@earendil-works/pi-agent-core";
@@ -111,6 +113,8 @@ interface WakeState {
pumping: Set<string>;
/** Throttle bookkeeping for the stale-job sweep. */
lastSweepAt: number;
stopConnectionWatch?: () => void;
automaticDeferred: WeakSet<Agent>;
}
interface LhWakeGlobal {
@@ -119,7 +123,7 @@ interface LhWakeGlobal {
function state(): WakeState {
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. */
@@ -210,9 +214,18 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
* 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
* 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 {
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) {
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
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
* reconciliation's re-pump can never hand a marker to the dying agent. */
export function unregisterLiveAgent(chatId: string): void {
const agent = state().liveAgents.get(chatId);
if (agent) state().automaticDeferred.delete(agent);
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
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
*
@@ -419,6 +448,7 @@ async function pump(chatId: string): Promise<void> {
const s = state();
if (s.pumping.has(chatId)) return;
s.pumping.add(chatId);
let parked = false;
try {
for (let attempt = 0; ; ) {
const queue = s.queues.get(chatId);
@@ -452,6 +482,7 @@ async function pump(chatId: string): Promise<void> {
return;
}
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
// 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,
// settlement re-pumps and lands them right after it). The pause is
// 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;
}
@@ -544,6 +576,15 @@ async function pump(chatId: string): Promise<void> {
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
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) {
const back = s.queues.get(chatId) ?? [];
back.push(...fire);
@@ -573,10 +614,30 @@ async function pump(chatId: string): Promise<void> {
// a live run are NOT stragglers (settleSteers owns their resurrection)
// — restarting for them would just park in waitForIdle forever.
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. */
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
+2
View File
@@ -52,6 +52,7 @@ import {
validateKeyShape,
} from "../core/auth-keys.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
// 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
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
if (provider === "chatgpt") return chatGPTConnected();
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
}
+18 -7
View File
@@ -20,6 +20,15 @@
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
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 { effectiveSettings } from "./stores.ts";
import { keyValue } from "./keys.ts";
@@ -125,7 +134,7 @@ export function catalogProviders(): CatalogProvider[] {
id: p.id,
envVar: p.envVar,
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
@@ -171,6 +180,7 @@ export function catalogContextLimits(): Map<string, number> {
index.set(`${provider.id}:${model.id}`, model.contextWindow);
}
}
for (const model of chatGPTModels()) index.set(`chatgpt:${model.id}`, model.contextWindow);
return index;
}
@@ -185,7 +195,8 @@ export function modelCatalog(): ModelCatalogResponse {
.filter((p) => keyed.has(p.id))
.flatMap((p) => piModels().getModels(p.id))
.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 {
models,
defaults: catalogDefaults(),
@@ -207,13 +218,13 @@ export type SettingsValidation =
* the model the picker writes against (settings.model, else config's
* prober/teacher pin; one controller, so the two specs share it). */
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
const providers = piModels().getProviders().map((p) => p.id);
const providers = connectionProviderIds();
if (settings.model) {
const { provider, id } = settings.model;
if (!providers.includes(provider)) {
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` };
}
}
@@ -237,13 +248,13 @@ export function validateModelsSettings(settings: ModelsSettings): SettingsValida
}
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) {
const { provider, id } = slot.model;
if (!providers.includes(provider)) {
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` };
}
}
@@ -265,7 +276,7 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
* or the level already supported — the existence gate rejects unknown
* models separately, so this only ever sees real ones in practice). */
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;
}
+5 -3
View File
@@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts";
import { graphRevision } from "./graph.ts";
import { catalogContextLimits } from "./model-catalog.ts";
import { listJobs } from "./jobs.ts";
import { jobCost } from "./pricing.ts";
import type { JobRecord } from "../core/contracts.ts";
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
@@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts";
* model is missing from the catalog (the blob then renders no context
* line — never a fake window). */
export interface ActivityJob extends JobRecord {
costUsd: number | null;
title: string;
contextLimit: number | null;
}
@@ -56,9 +58,8 @@ export interface ActivityExtraction {
/** The dock's whole world in one payload: every running process, every view.
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
* the blob disappears when the process ends. Cost math is NOT done here:
* raw usage rides on the records and the client folds it with the pure
* helpers in core/prices.ts (transport stays thin). */
* the blob disappears when the process ends. Costs are joined here through
* the server-side Pi pricing adapter; clients never reconstruct rates. */
/** The dock's whole world + the graph's version stamp (wave 6 session
* deletion): the payload carries graphRevision (max mtime across the
* graph data files — see server/graph.ts) so any view can detect "the
@@ -78,6 +79,7 @@ export function activityFeed(): {
if (job.status === "running") {
jobs.push({
...job,
costUsd: jobCost(job),
title: chat.title ?? chat.id,
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
});
+19 -3
View File
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-c
import type { SteerMessage } from "../../core/contracts.ts";
import { noteSteerVisible } from "../jobs.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
@@ -71,6 +72,7 @@ export type TurnWireEvent =
| { type: "error"; errorText: string };
export interface PiTurnSpec {
requestCycle?: ChatGPTRequestCycle;
/** The per-turn agent (stateless engine — built from the record). */
agent: Agent;
/** The handler's error-masking policy for LOOP-level failures (the
@@ -117,7 +119,7 @@ export interface 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;
let settled = false;
@@ -248,11 +250,24 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
settleWith();
}
};
agent.subscribe(handle);
requestCycle?.bindRelease(handle);
agent.subscribe((event) => {
if (!requestCycle?.hold(event)) handle(event);
});
void (async () => {
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) {
// A loop-level failure (not a provider error — those ride the
// stream as stopReason "error"): surface, close, persist. If the
@@ -318,6 +333,7 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
abort() {
if (settled) return;
sawAbort = true;
requestCycle?.abort();
agent.abort();
},
};
+50
View File
@@ -0,0 +1,50 @@
/** Read-time API-dollar estimates. Pi owns both rates and arithmetic.
* Catalog resolution is key-independent; no provider dispatch/auth is needed.
* Never mutate saved native messages: calculateCost mutates its usage.cost.
*/
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts";
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
function catalogModel(id: string) {
const colon = id.indexOf(":");
if (colon < 0 || isChatGPTPlan(id)) return undefined;
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
}
export function callCost(call: Partial<CallUsage>, modelId: string): number | null {
const model = catalogModel(modelId);
if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null;
const input = call.inputTokens;
const output = call.outputTokens;
const cacheRead = call.cacheReadTokens ?? 0;
const cacheWrite = call.cacheWriteTokens ?? 0;
const cacheWrite1h = call.cacheWrite1hTokens ?? 0;
if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null;
// Old records discarded this split. Only price them if it cannot affect
// the result; do not invent short-retention precision from a missing field.
if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined &&
[model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null;
const usage: Usage = {
input, output, cacheRead, cacheWrite, cacheWrite1h,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const total = calculateCost(model, usage).total;
return Number.isFinite(total) && total >= 0 ? total : null;
}
export function turnCost(turn: TurnUsage): number | null {
return foldTurnCost(turn, callCost);
}
export function sessionCost(record: Pick<ChatRecord, "turns">) {
return foldSessionCost(record, callCost);
}
export function jobCost(job: Pick<JobRecord, "usage">): number | null {
const usage = job.usage;
if (!usage || usage.steps < 1) return null;
const hasCalls = usage.calls?.length === usage.steps;
if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null;
return foldJobCost(job, callCost);
}
+3
View File
@@ -88,10 +88,13 @@ test("activityFeed: contextLimit is the server's live-catalog join (owner amendm
// pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was
// 1_048_576 at 0.87.1 — upstream models.dev refresh).
assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed");
assert.ok(knownJob.costUsd !== null && Math.abs(knownJob.costUsd - 0.0002) < 1e-12, "Pi's price is projected on the server");
const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!;
assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window");
assert.equal(unknownJob.costUsd, null);
const bareJob = feed.jobs.find((j) => j.id === bare.id)!;
assert.equal(bareJob.contextLimit, null, "no usage yet → no context line");
assert.equal(bareJob.costUsd, null);
} finally {
fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`);
const jobsDir = path.join(PATHS.data, "jobs");
+591
View File
@@ -0,0 +1,591 @@
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, fileStore, 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>; read?: () => 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: { async read() { options.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 file 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("rotated-token persistence failure invalidates existing leases and never reloads the stale saved token", async () => {
let failWrite = false, reads = 0, refreshes = 0;
const pushes: any[] = [];
const f = await loggedIn({ read: () => { reads++; }, change: (push) => pushes.push(push),
write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); },
token: async (form, url) => {
if (form.get("grant_type") === "refresh_token") {
refreshes++;
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", token_type: "Bearer", expires_in: 3600 });
}
return Response.json({ access_token: "old-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
},
});
try {
const catalog = await f.service.models();
const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
await f.rawLease(false, expected);
// Advance time rather than mutating the service's private state.
const now = Date.now; Date.now = () => now() + 3600000;
try {
failWrite = true;
await assert.rejects(f.rawLease(false, expected), /storage/);
} finally { Date.now = now; }
assert.equal(refreshes, 1);
assert.equal(f.saved.registrations[0].tokens.refreshToken, "old-refresh", "failed write leaves only the stale predecessor on disk");
assert(pushes.some((push) => push.invalidate === "all" && push.stamp.epoch > catalog.epoch));
failWrite = false;
await f.service.status(); await f.service.models(); f.service.resume(); await f.service.consent(true);
await assert.rejects(f.rawLease(false, expected)); await assert.rejects(f.rawLease(true, expected));
assert.equal((await f.service.status()).accounts[0].connected, false);
assert.equal(reads, 1); assert.equal(refreshes, 1, "no stale-token retry after storage recovers");
} 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("private JSON file round-trips global state; creation and atomic replacement are owner-only", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
const file = path.join(dir, "private", "chatgpt-credentials.json");
try {
const store = fileStore(file);
assert.equal(await store.read(), null);
const data = { ...initial(), allowBackgroundUse: false };
await store.write(data);
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), data, "ordinary JSON, not an encrypted envelope");
assert.deepEqual(await store.read(), data);
assert.equal((await fs.stat(path.dirname(file))).mode & 0o777, 0o700);
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
await fs.chmod(file, 0o644);
await store.write({ ...data, allowBackgroundUse: true });
assert.equal((await fs.stat(file)).mode & 0o777, 0o600, "replacement never inherits a permissive old mode");
assert.equal((await store.read()).allowBackgroundUse, true);
assert.deepEqual(await fs.readdir(path.dirname(file)), ["chatgpt-credentials.json"]);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
test("revocation endpoint remains restricted to the exact secure issuer", () => {
assert(allowedAuthEndpoint("https://auth.openai.com/api/accounts/oauth/revoke"));
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"));
});
for (const holdVerification of [false, true]) test(`invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart${holdVerification ? " (held JWKS regression)" : ""}`, { timeout: 10000 }, 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 reconnectComplete = deferred<any>();
const verificationEntered = deferred(), verificationRelease = deferred();
let reconnectArmed = false, sawPending = false, completed = false;
const restarted = fixture({ state: f.saved,
change: (push) => {
if (!reconnectArmed) return;
if (push.status.pending) sawPending = true;
else if (sawPending) { completed = true; reconnectComplete.resolve(push.status); }
},
provider: async (url) => {
if (holdVerification && reconnectArmed && url.endsWith("jwks.json")) {
verificationEntered.resolve(); await verificationRelease.promise;
}
return undefined;
},
});
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);
// Earlier select/consent/logout notifications have already settled changed.
// Arm before this login and await its pending -> terminal transition instead.
reconnectArmed = true;
await restarted.service.login(restricted.id);
assert.equal(sawPending, true);
await restarted.callback(); // HTTP acknowledgement precedes verification/save.
if (holdVerification) {
await verificationEntered.promise;
assert.equal(completed, false, "the current attempt cannot complete before identity verification");
const held = await restarted.service.status();
assert.equal(held.pending, true); assert.equal(held.error, null);
assert.equal(held.accounts[0].connected, false); assert.equal(held.accounts[0].planEnabled, false);
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
assert.deepEqual((await restarted.service.models()).models, []);
const expected = { expectedAccountId: restricted.id, expectedAuthorityRevision: restarted.service.stamp().authorityRevision, selectedModelId: "gpt-5" };
await assert.rejects(restarted.rawLease(false, expected), /disconnected/);
await assert.rejects(restarted.rawLease(true, expected), /disconnected/);
assert.equal(completed, false, "status/catalog/lease checks cannot settle the pending attempt");
verificationRelease.resolve();
}
const terminal = await reconnectComplete.promise;
assert.equal(terminal.pending, false); assert.equal(terminal.error, null);
assert.equal(terminal.activeId, restricted.id);
assert.equal(terminal.accounts[0].connected, true);
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
} finally { verificationRelease.resolve(); restarted.service.close(); }
} finally { f.service.close(); }
});
test("observed file write failure invalidates all work; status, consent and resume cannot reload stale credentials or repair authority", async () => {
let lost = false, reads = 0; const pushes: any[] = [];
const f = await loggedIn({ read: () => { reads++; }, write: async () => { if (lost) throw Object.assign(new Error("storage"), { code: "storage" }); }, change: (push) => pushes.push(push) });
try {
await f.service.lease(false); const epoch = f.service.stamp().epoch;
lost = true;
await assert.rejects(f.service.consent(false), /storage/);
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 f.service.status(); await f.service.models();
await assert.rejects(f.service.lease(false));
assert.equal((await f.service.status()).accounts[0].connected, false);
assert.equal(reads, 1, "a recovered disk never causes a stale credential reload");
await f.service.login(f.saved.activeId); await f.callback();
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((resolve) => setImmediate(resolve));
assert.equal((await f.service.status()).accounts[0].connected, true, "verified login plus durable save can explicitly reconnect");
} finally { f.service.close(); }
});
test("strict JSON 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, "chatgpt-credentials.json");
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(state);
await fs.writeFile(file, bytes);
const service = createChatGPT({ store: fileStore(file), 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 stored 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(); }
});
+93
View File
@@ -0,0 +1,93 @@
import { test, before, 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 } });
}
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
before(() => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: true });
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { callback?.(new Error("unexpected fixture desktop request")); return false; } });
});
after(() => {
fs.rmSync(dir, { 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;
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
});
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");
});
}
+52
View File
@@ -0,0 +1,52 @@
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 desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
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(() => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
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 (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
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));
});
+443
View File
@@ -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 read or saved. Check local file access, then reconnect before making requests.";
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
function 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"));
});
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-dev-ipc-"));
process.env.LH_DATA_DIR = scratch;
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
process.env.OPENAI_API_KEY = "sk-fixture-not-used-for-generation";
const { GET } = await import("../app/api/models/route.ts");
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
const send = Object.getOwnPropertyDescriptor(process, "send");
const connected = Object.getOwnPropertyDescriptor(process, "connected");
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
const originalFetch = globalThis.fetch;
let sends = 0;
try {
globalThis.fetch = async () => { throw new Error("provider/network calls forbidden in catalog fixture"); };
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { sends++; callback?.(new Error("not a desktop parent")); return false; } });
Object.defineProperty(process, "connected", { configurable: true, value: true });
for (const [mode, capability] of [[undefined, undefined], ["1", undefined], [undefined, "d".repeat(64)], ["1", "short"]]) {
if (mode === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = mode;
if (capability === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = capability;
// A framework parent cannot establish ChatGPT state by sharing only IPC.
(process.emit as (name: string, value: unknown) => boolean)("message", { channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 1 }, status: { available: true, activeId: "fixture", accounts: [{ id: "fixture", connected: true, planEnabled: true }] } });
const response = await GET();
assert.equal(response.status, 200);
const catalog = await response.json();
assert.ok(catalog.models.some((m: { provider: string }) => m.provider === "openai"));
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "openai").keyPresent, true);
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
assert.equal(chatGPTConnected(), false);
assert.deepEqual(chatGPTModels(), []);
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
}
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
Object.defineProperty(process, "connected", { configurable: true, value: false });
await GET();
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
assert.equal(sends, 0, "desktop markers without connected IPC do not suffice");
} finally {
globalThis.fetch = originalFetch;
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;
if (marker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = marker;
if (secret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = secret;
}
});
+125
View File
@@ -0,0 +1,125 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { createChatGPT, fileStore } = require("../electron/chatgpt.cjs");
const initial = () => ({ version: 1, hostId: "urn:uuid:scratch-host", activeId: null, registrations: [] });
const account = (id: string, subject: string) => ({
id, clientId: `oaiapp_${id}`, issuer: "https://auth.openai.com", subject, email: `${subject}@example.invalid`,
tokens: { accessToken: `scratch-access-${id}`, refreshToken: `scratch-refresh-${id}`, idToken: `scratch-id-${id}`, expiresAt: Date.now() + 3600000, scopes: ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"] },
});
function service(store: unknown) {
return createChatGPT({ store, openExternal: () => assert.fail("no browser sign-in expected"), fetch: () => assert.fail("no provider request expected") });
}
test("ordinary startup restores saved accounts and global OFF from JSON without OAuth or native storage", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-restore-"));
const file = path.join(dir, "chatgpt-credentials.json");
const state = { ...initial(), activeId: "B", registrations: [account("A", "first"), account("B", "second")], allowBackgroundUse: false };
let connection;
try {
await fileStore(file).write(state);
connection = service(fileStore(file));
const status = await connection.status();
assert.equal(status.available, true);
assert.equal(status.activeId, "B");
assert.equal(status.allowBackgroundUse, false);
assert.deepEqual(status.accounts.map((a: { email: string; connected: boolean }) => [a.email, a.connected]), [["first@example.invalid", true], ["second@example.invalid", true]]);
assert.doesNotMatch(JSON.stringify(status), /scratch-access|scratch-refresh|scratch-id/);
await connection.status();
assert.deepEqual(await fileStore(file).read(), state, "startup/status does not change saved credentials");
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
});
test("new store ignores experimental encrypted files and never alters the separate API-key file", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-fresh-"));
const file = path.join(dir, "chatgpt-credentials.json");
const experimental = path.join(dir, "chatgpt-credentials.enc.json");
const auth = path.join(dir, "auth.json");
const oldBytes = "not an envelope to parse, decrypt, migrate or delete";
const apiBytes = '{"OPENAI_API_KEY":"scratch-api-key"}\n';
let connection;
try {
await fs.writeFile(experimental, oldBytes);
await fs.writeFile(auth, apiBytes);
connection = service(fileStore(file));
const status = await connection.status();
assert.equal(status.available, true); assert.equal(status.allowBackgroundUse, true);
assert.equal(status.activeId, null); assert.deepEqual(status.accounts, []);
assert.deepEqual((await connection.models()).models, []);
await connection.consent(false);
connection.close(); connection = service(fileStore(file));
assert.equal((await connection.status()).allowBackgroundUse, false, "zero-account preference survives a real file-backed restart");
assert.equal(await fs.readFile(experimental, "utf8"), oldBytes);
assert.equal(await fs.readFile(auth, "utf8"), apiBytes);
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
});
for (const bytes of ["{broken-json", "null", "[]", '{"version":1,"encrypted":"ZXhwZXJpbWVudA=="}']) {
test(`invalid JSON/record fails closed without overwriting it: ${bytes}`, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-invalid-"));
const file = path.join(dir, "chatgpt-credentials.json");
let connection;
try {
await fs.writeFile(file, bytes);
connection = service(fileStore(file));
const status = await connection.status();
assert.equal(status.available, false); assert.equal(status.activeId, null);
assert.match(status.error, /could not be read or saved/);
assert.deepEqual((await connection.models()).models, []);
await assert.rejects(connection.login(), /storage/);
assert.equal(await fs.readFile(file, "utf8"), bytes);
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
});
}
for (const phase of ["sync", "rename"] as const) {
test(`failed ${phase} leaves the previous JSON intact and removes temporary files`, async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-atomic-"));
const file = path.join(dir, "chatgpt-credentials.json");
try {
const store = fileStore(file);
await store.write(initial());
const before = await fs.readFile(file, "utf8");
if (phase === "rename") t.mock.method(fs, "rename", async () => { throw new Error("scratch rename failure"); });
else {
const open = fs.open.bind(fs);
t.mock.method(fs, "open", async (...args: Parameters<typeof fs.open>) => {
const handle = await open(...args);
if (args[1] === "wx") t.mock.method(handle, "sync", async () => { throw new Error("scratch sync failure"); });
return handle;
});
}
await assert.rejects(store.write({ ...initial(), allowBackgroundUse: false }), /^Error: storage$/);
assert.equal(await fs.readFile(file, "utf8"), before);
assert.deepEqual(await fs.readdir(dir), ["chatgpt-credentials.json"]);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
}
test("file read/write failures are sanitized and cannot leak credential or path details", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-io-"));
try {
// Reading a directory and writing beneath a regular file both fail on all supported platforms.
await assert.rejects(fileStore(dir).read(), /^Error: storage$/);
const blocker = path.join(dir, "scratch-private-path"); await fs.writeFile(blocker, "blocker");
const connection = service(fileStore(path.join(blocker, "chatgpt-credentials.json")));
try {
const status = await connection.status();
assert.equal(status.available, false);
assert.doesNotMatch(status.error, /scratch-private-path|ENOTDIR|EISDIR/);
await assert.rejects(connection.login(), /^Error: storage$/);
} finally { connection.close(); }
await assert.rejects(fileStore(path.join(blocker, "credentials.json")).write({ ...initial(), secret: "scratch-token" }), /^Error: storage$/);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
test("desktop wiring uses only the private JSON path, with no Electron safeStorage dependency", async () => {
const main = await fs.readFile(new URL("../electron/main.cjs", import.meta.url), "utf8");
const auth = await fs.readFile(new URL("../electron/chatgpt.cjs", import.meta.url), "utf8");
assert.match(main, /store: fileStore\(path\.join\(app\.getPath\("userData"\), "chatgpt-credentials\.json"\)\)/);
assert.doesNotMatch(main + auth, /safeStorage|encryptedStore|chatgpt-credentials\.enc\.json/);
});
+158
View File
@@ -0,0 +1,158 @@
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 desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
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(() => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
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(() => {
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
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");
});
+178
View File
@@ -0,0 +1,178 @@
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 desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
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 () => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
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(() => {
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
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 credential-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();
});
+84
View File
@@ -0,0 +1,84 @@
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 as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, () => null);
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);
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { callCost, sessionCost, turnCost, jobCost } from "../src/server/pricing.ts";
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
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, turnCosts: { "scratch-turn": null } });
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);
});
+55
View File
@@ -0,0 +1,55 @@
/** Golden outputs from the verified published pi-coding-agent 1.4.0
* findCutPoint + estimateTokens helpers, executed in isolation on synthetic
* message/metadata entries. No coding-agent dependency or private data.
* Source compaction.js SHA256:
* f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6
* Update deliberately alongside the documented local port, not automatically.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { compactionCutIndex, estimateMessageTokens, newCompactionMarker, KEEP_RECENT_TOKENS, type LhMessage } from "../src/core/index.ts";
let serial = 0;
const user = (chars: number): LhMessage => ({ id: `u${serial++}`, role: "user", content: "u".repeat(chars), timestamp: 1 });
const assistant = (chars: number): LhMessage => ({ id: `a${serial++}`, role: "assistant", content: [{ type: "text", text: "a".repeat(chars) }], timestamp: 1 }) as LhMessage;
const call = (chars: number): LhMessage => ({ ...assistant(chars), content: [{ type: "text", text: "a".repeat(chars) }, { type: "toolCall", name: "quiz", arguments: {}, id: "c" }] }) as LhMessage;
const result = (chars: number): LhMessage => ({ id: `r${serial++}`, role: "toolResult", content: [{ type: "text", text: "r".repeat(chars) }], toolCallId: "c", toolName: "quiz", isError: false, timestamp: 1 });
const image = (): LhMessage => ({ id: `i${serial++}`, role: "user", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], timestamp: 1 });
const metadata = (): LhMessage => ({ id: `m${serial++}`, role: "custom", customType: "plan-block", content: "invisible".repeat(1000), display: false, details: {}, timestamp: 1 });
test("local cutoff matches published native golden boundaries and estimator values", () => {
const cases: [string, LhMessage[], number, number, number[]][] = [
["exact threshold", [user(40), user(40), user(40)], 20, 1, [10, 10, 10]],
["under budget", [user(4), assistant(4)], 20, 0, [1, 1]],
["split turn", [user(400), assistant(40), assistant(40)], 15, 1, [100, 10, 10]],
["completed result boundary", [user(400), call(4), result(400), user(40)], 30, 3, [100, 3, 100, 10]],
["oversized trailing result", [user(400), user(40), call(4), result(400)], 30, 2, [100, 10, 3, 100]],
["multiple trailing results", [user(400), call(4), result(200), result(200)], 30, 1, [100, 3, 50, 50]],
["image allowance", [user(400), image(), user(40)], 1000, 1, [100, 1200, 10]],
["empty trailing text", [user(400), assistant(0), user(0)], 30, 0, [100, 0, 0]],
["pending call", [user(400), user(400), call(4)], 10, 1, [100, 100, 3]],
];
for (const [name, messages, budget, expected, tokens] of cases) {
assert.equal(compactionCutIndex(messages, budget), expected, name);
assert.deepEqual(messages.map(estimateMessageTokens), tokens, name);
}
assert.equal(compactionCutIndex([user(400), metadata(), assistant(40), metadata(), user(40)], 15), 1, "adjacent invisible metadata stays with the cut");
assert.equal(KEEP_RECENT_TOKENS, 20_000);
});
test("chained cutoff operates on active retained history, never the previous summary or discarded prefix", () => {
const old = user(400_000);
const kept = user(40);
const marker = newCompactionMarker("summary".repeat(100_000), { firstKeptMessageId: kept.id, tokensBefore: 100_000, at: "fixture" });
const messages = [old, kept, assistant(40), marker, user(40)];
assert.equal(compactionCutIndex(messages, 100), 1, "retained tail fits; no progress");
assert.equal(compactionCutIndex(messages, 15), 2, "old summary does not consume the kept-tail budget");
});
test("opaque signatures remain unmeasured and preserved; system/image allowances match native", () => {
const signed = { ...assistant(4), content: [{ type: "thinking", thinking: "", thinkingSignature: "opaque".repeat(100_000) }] } as LhMessage;
assert.equal(estimateMessageTokens(signed), 0);
assert.equal((signed as { content: { thinkingSignature: string }[] }).content[0]!.thinkingSignature.length, 600_000);
const system = { role: "system", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], sections: { a: "abcd" }, toolsAdded: [{ name: "fixture" }] };
assert.equal(estimateMessageTokens(system as never), Math.ceil((4800 + 4 + JSON.stringify(system.toolsAdded).length) / 4));
});
+49 -1
View File
@@ -32,7 +32,7 @@ const {
} = await import("../src/server/compaction.ts");
import type { CompactionDeps } from "../src/server/compaction.ts";
const { subscribe } = await import("../src/core/events.ts");
const { activeCompactionOf, newUserMessage, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
import type { LhMessage } from "../src/core/index.ts";
after(() => {
@@ -74,6 +74,23 @@ const depsOf = (chatId: string, thresholdTokens: number): CompactionDeps => ({
// ── the laws ────────────────────────────────────────────────────────────────
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 () => {
const chatId = overThresholdChat("compact: basic");
const events: string[] = [];
@@ -187,6 +204,37 @@ describe("the compaction service", () => {
kickCompaction(() => null, chatId);
});
it("a usage-heavy zero cut does not spend or append a marker", async () => {
const chat = chatStore.createChat("no-progress fixture");
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
chatStore.saveMessages(chat.id, [newUserMessage("tiny visible history"), assistant]);
const before = summarizeCalls;
await ensureCompacted(depsOf(chat.id, 40_000), chat.id);
assert.equal(summarizeCalls, before);
assert.equal(activeCompactionOf(chatStore.getChat(chat.id)!.messages), null);
assert.equal(measuredContextTokens(chatStore.getChat(chat.id)!.messages), 120_001);
});
it("starting/failing compaction preserves the meter; committing its marker clears it, regardless of summary usage", async () => {
const id = overThresholdChat("meter validity fixture");
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
chatStore.saveMessages(id, [...chatStore.getChat(id)!.messages, assistant]);
let release!: () => void;
const gate = new Promise<void>((done) => { release = done; });
const deps = depsOf(id, 40_000);
const running = ensureCompacted({ ...deps, summarize: async () => {
await gate;
throw new Error("fixture failure");
} }, id);
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
release(); await running;
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
await ensureCompacted(deps, id);
const record = chatStore.getChat(id)!;
assert.ok(record.turns!.at(-1)!.steps[0]!.inputTokens! > 0);
assert.equal(measuredContextTokens(record.messages), null);
});
it("a summarizer failure leaves the record untouched and the gate re-arms", async () => {
const chatId = overThresholdChat("compact: fail");
const before = summarizeCalls;
+8 -8
View File
@@ -156,7 +156,7 @@ describe("compactionCutIndex", () => {
assert.equal((messages[cut] as { content: string }).content.startsWith("m2"), true, "the oldest kept message sits at the cut");
});
it("NEVER separates a tool result from its call — the cut extends to the call's assistant message", () => {
it("native cut after an oversized result drops both call and result, never keeps an orphan", () => {
const messages = [
big("m1"),
big("m2"),
@@ -165,13 +165,13 @@ describe("compactionCutIndex", () => {
big("m3"),
big("m4"),
];
// Budget lands such that the naive stop is ON the toolResult (m2 kept,
// result next): the walk must extend to a1's assistant message.
// Budget lands on the result. Pi chooses the next valid cut AT OR
// AFTER it, dropping the complete exchange rather than extending.
const cut = compactionCutIndex(messages, estimateTokens([big("m3"), big("m4")]) + 1);
assert.ok(cut <= 2, `the cut must not sit between the call (idx 2) and its result (idx 3) — got ${cut}`);
const kept = messages.slice(cut);
const keptIds = new Set(kept.map((m) => m.id));
assert.ok(keptIds.has("a1") && keptIds.has("r_call_1"), "call and result stay together");
assert.equal(cut, 4);
const keptIds = new Set(messages.slice(cut).map((m) => m.id));
assert.equal(keptIds.has("a1"), false);
assert.equal(keptIds.has("r_call_1"), false);
});
it("a PENDING question call (no result — the pose→answer window) always survives", () => {
@@ -181,7 +181,7 @@ describe("compactionCutIndex", () => {
assert.ok(kept.some((m) => m.id === "a_pending"), "the pending pose is never dropped");
});
it("zero budget summarizes everything; whole-history-in-budget returns 0 only when nothing precedes", () => {
it("whole history within budget keeps everything (zero cut)", () => {
assert.equal(compactionCutIndex([newUserMessage("hi")], 100), 0);
});
});
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { measuredContextTokens, newCompactionMarker, type LhMessage } from "../src/core/index.ts";
import { SessionModel } from "../components/session-core.ts";
function assistant(id: string, stopReason: AssistantMessage["stopReason"] = "stop", total = 999_999): LhMessage {
return { id, role: "assistant", content: [{ type: "text", text: "fixture" }], api: "openai-completions", provider: "openrouter", model: "fixture", stopReason, timestamp: 1,
usage: { input: 10_000, output: 1_000, cacheRead: 70_000, cacheWrite: 2_000, reasoning: 500, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
}
const marker = () => newCompactionMarker("educational summary", { firstKeptMessageId: "old", tokensBefore: 110_001, at: "fixture" });
test("meter sums four disjoint fields, ignoring reasoning subset and totalTokens overrides", () => {
assert.equal(measuredContextTokens([assistant("old")]), 83_000);
assert.equal(measuredContextTokens([]), null);
for (const stop of ["stop", "length", "toolUse"] as const) assert.equal(measuredContextTokens([assistant("a", stop)]), 83_000);
});
test("newest marker invalidates retained pre-cut usage; summarizer money log cannot supply a measurement", () => {
const old = assistant("old");
assert.equal(measuredContextTokens([old, marker()]), null);
assert.equal(measuredContextTokens([old, marker(), assistant("fresh")]), 83_000);
assert.equal(measuredContextTokens([old, marker(), assistant("fresh"), marker()]), null);
// The summarizer is logged as a money-channel turn, never an assistant
// in the native conversation list. No money-channel input exists here.
});
test("errors, aborts, all-zero/invalid usage and unfinished slots preserve the previous valid reading", () => {
const old = assistant("old");
for (const stop of ["error", "aborted"] as const) assert.equal(measuredContextTokens([old, assistant("bad", stop)]), 83_000);
const zero = assistant("zero") as AssistantMessage & { id: string };
zero.usage = { ...zero.usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
assert.equal(measuredContextTokens([old, zero]), 83_000);
assert.equal(measuredContextTokens([zero]), null);
assert.equal(measuredContextTokens([old, assistant("partial")], new Set(["partial"])), 83_000);
assert.equal(measuredContextTokens([marker(), assistant("bad", "error")]), null);
});
test("live terminal-looking message_update is not completion; each message_end updates immediately", () => {
const model = new SessionModel({ chatId: "fixture", initialMessages: [assistant("old")] });
const fresh = assistant("live") as AssistantMessage & { id: string };
fresh.usage = { ...fresh.usage, input: 20_000 };
model.applyWireEvent({ type: "message_start", message: fresh });
model.applyWireEvent({ type: "message_update", message: fresh });
assert.equal(model.getState().contextTokens, 83_000);
model.applyWireEvent({ type: "message_end", message: fresh });
assert.equal(model.getState().contextTokens, 93_000);
model.applyWireEvent({ type: "message_start", message: assistant("next") });
assert.equal(model.getState().contextTokens, 93_000);
model.applyWireEvent({ type: "message_end", message: assistant("next", "toolUse") });
assert.equal(model.getState().contextTokens, 83_000);
model.setMessages([assistant("old"), marker()]);
assert.equal(model.getState().contextTokens, null);
});
+4 -3
View File
@@ -8,16 +8,17 @@
import assert from "node:assert/strict";
import { describe, it as test } from "node:test";
import {
graphCostSummary,
graphCostSummary as foldGraphCostSummary,
type CostSummaryInput,
type GraphCostSummary,
} from "../src/core/cost-summary.ts";
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
import { PRICES, callCost } from "../src/core/prices.ts";
import { callCost } from "../src/server/pricing.ts";
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
/** A model with full published pricing (from the generated registry). */
const PRICED = "openrouter:z-ai/glm-5.3-flash";
assert.ok(PRICES[PRICED], "fixture model must exist in PRICES");
assert.notEqual(callCost({ inputTokens: 1, outputTokens: 1 }, PRICED), null);
const step = (input: number, output: number): CallUsage => ({
inputTokens: input,
+94
View File
@@ -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);
});
+3 -2
View File
@@ -10,6 +10,7 @@ import { describe, it } from "node:test";
const require = createRequire(import.meta.url);
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
const mainSource = readFileSync(mainFile, "utf8");
const mainRequire = createRequire(mainFile);
/** Decline the single-instance lock so normal boot never spawns a server.
* Then call the REAL createWindow with a stub Electron API and inspect its
@@ -27,7 +28,7 @@ function windowOptions(platform: string) {
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = {
require: (id: string) => id === "electron" ? electron : require(id),
require: (id: string) => id === "electron" ? electron : mainRequire(id),
process: { platform, argv: [], env: {}, on() {} },
__dirname: dirname(mainFile),
};
@@ -77,7 +78,7 @@ describe("desktop window chrome", () => {
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [
"expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
]);
});
});
+60
View File
@@ -0,0 +1,60 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { GraphCostControl } from "../components/graph-view.tsx";
import { graphCostSummary } from "../src/core/cost-summary.ts";
import { callCost } from "../src/server/pricing.ts";
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
const usage = (over: Partial<CallUsage> = {}): CallUsage => ({ inputTokens: 1000, outputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 20_000, reasoningTokens: 0, ...over });
const run = (steps: CallUsage[], model = "anthropic:claude-haiku-4-5", at = 1): ExtractionRunUsage => ({ startedAt: at, at: "fixture", durationMs: 1, stages: { extractor: { model, steps }, auditor: { model, steps } } });
const summary = (runs: ExtractionRunUsage[]) => graphCostSummary({ chats: [{ title: "fixture", extractions: runs }], sources: [] }, callCost);
const headline = (runs: ExtractionRunUsage[]) => renderToStaticMarkup(<GraphCostControl costs={summary(runs)} />);
test("wholly unpriced historical graph costs render unknown, not known-zero dollars", () => {
const historical = run([usage()]); // missing long-cache split
const costs = summary([historical]);
assert.equal(costs.totalDollars, 0);
assert.equal(costs.unpricedRuns, 1);
assert.equal(costs.stages.extraction.unpricedRuns, 1);
const html = headline([historical]);
assert.match(html, />—<\/button>/);
assert.doesNotMatch(html, /\$0\.00/);
});
test("known zero, mixed priced/unknown, and subscription headline semantics stay distinct", () => {
const zero = run([usage({ inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0 })]);
assert.match(headline([zero]), />\$0\.00<\/button>/, "known zero is not unknown");
const priced = run([usage({ cacheWrite1hTokens: 0 })]);
const historical = run([usage()], undefined, 2);
const mixed = summary([priced, historical]);
assert.equal(mixed.unpricedRuns, 1);
assert.ok(mixed.totalDollars > 0);
assert.match(headline([priced, historical]), new RegExp(`>\\$${mixed.totalDollars.toFixed(2).replace(".", "\\.")}<\\/button>`));
const plan = run([usage()], "chatgpt:fixture");
assert.match(headline([plan]), />ChatGPT plan<\/button>/);
assert.match(headline([plan, zero]), />\$0\.00 API \+ ChatGPT plan<\/button>/);
});
test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => {
const costs = summary([run([usage({ cacheWrite1hTokens: 20_000 })]), run([usage({ cacheWrite1hTokens: 5000 })], undefined, 2)]);
assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, 25_000);
assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, 25_000);
assert.deepEqual(costs.runs.map((r) => r.tokens.cacheWrite1hTokens), [10_000, 40_000]);
});
test("unknown historical long-cache splits stay unknown regardless of merge order", () => {
const known = run([usage({ cacheWrite1hTokens: 20_000 })]);
const historical = run([usage()], undefined, 2);
for (const runs of [[known, historical], [historical, known]]) {
const costs = summary(runs);
assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, undefined);
assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, undefined);
}
const mixedWithinRun = summary([run([usage({ cacheWrite1hTokens: 20_000 }), usage()])]);
assert.equal(mixedWithinRun.stages.extraction.tokens.cacheWrite1hTokens, undefined);
const absentNoWrites = run([usage({ cacheWriteTokens: 0 })], undefined, 2);
assert.equal(summary([absentNoWrites, known]).stages.extraction.tokens.cacheWrite1hTokens, 20_000, "absence without writes must not poison known write detail");
});
+5
View File
@@ -346,8 +346,11 @@ describe("steer helpers (contracts.ts)", () => {
cacheReadTokens: 4,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
cacheWrite1hTokens: undefined,
calls: [{ inputTokens: 10, outputTokens: 5, cacheReadTokens: 4, cacheWriteTokens: undefined, cacheWrite1hTokens: undefined, reasoningTokens: undefined }],
steps: 1,
lastStep: {
cacheWrite1hTokens: undefined,
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 4,
@@ -364,7 +367,9 @@ describe("steer helpers (contracts.ts)", () => {
assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero");
assert.equal(total.reasoningTokens, undefined);
assert.equal(total.steps, 2, "one step per model call, reported or not");
assert.equal(total.calls?.length, 2);
assert.deepEqual(total.lastStep, {
cacheWrite1hTokens: undefined,
inputTokens: 20,
outputTokens: 0,
cacheReadTokens: undefined,
+1 -1
View File
@@ -4,7 +4,7 @@ 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";
import { callCost } from "../src/server/pricing.ts";
const specimens = [
{ id: "recursive/flint-v1.0:free", input: ["text"] },
-56
View File
@@ -1,56 +0,0 @@
/**
* THE PRICE-TABLE DRIFT GUARD (owner, 2026-09-29).
*
* Failure mode this kills: bumping @earendil-works/pi-ai adds new models
* (and price corrections) to the shipped catalogs, but nobody re-runs
* scripts/sync-pricing.ts — src/core/prices.ts stays a stale generation
* and every run on a new model renders cost as "—" (deepseek-v4.1-flash,
* 2026-09-29, commit df654fb). The table must stay a generated static
* file (core is client-safe: activity-dock and stats-bar fold costs in
* the renderer, and reading the catalog JSON needs fs), so staleness is
* guarded instead of eliminated.
*
* The test asserts PRICES equals EXACTLY what the sync script would
* generate right now — it imports the script's own builder, so the guard
* and the generator can never disagree:
* - a catalog model with pricing missing from PRICES → "run the sync"
* - a price field that drifted in the catalog → "run the sync"
* - a PRICES key whose model left the catalog → "run the sync"
*
* On failure: npx tsx scripts/sync-pricing.ts, commit the regenerated
* file, re-derive any pinned-cost tests.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { PRICES } from "../src/core/prices.ts";
import { buildPrices } from "../scripts/sync-pricing.ts";
test("PRICES matches pi-ai's shipped catalogs exactly — the sync was run after the last bump", () => {
const catalog = buildPrices();
const missing = [...catalog.keys()].filter((k) => !(k in PRICES));
const stale = Object.keys(PRICES).filter((k) => !catalog.has(k));
const drifted: string[] = [];
for (const [key, cost] of catalog) {
const price = PRICES[key];
if (!price) continue;
if (
price.input !== cost.input ||
price.output !== cost.output ||
price.cacheRead !== cost.cacheRead ||
price.cacheWrite !== cost.cacheWrite
) {
drifted.push(key);
}
}
assert.equal(
`${missing.length} / ${stale.length} / ${drifted.length}`,
"0 / 0 / 0",
`price table is stale: ${missing.length} unpriced catalog models (e.g. ${missing[0] ?? "—"}), ` +
`${stale.length} keys gone from the catalog (e.g. ${stale[0] ?? "—"}), ` +
`${drifted.length} drifted prices (e.g. ${drifted[0] ?? "—"}). ` +
"Run: npx tsx scripts/sync-pricing.ts, commit the regenerated file, re-derive pinned-cost tests.",
);
});
+45
View File
@@ -0,0 +1,45 @@
/** Catalog/calculator parity replaces the old generated-table drift test. */
import assert from "node:assert/strict";
import test from "node:test";
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../src/agent/models.ts";
import { callCost, jobCost } from "../src/server/pricing.ts";
import { accumulateJobUsage, piUsageToStep } from "../src/core/contracts/jobs.ts";
const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.id));
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
assert.ok(models.length > 100);
for (const model of models) {
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
for (const input of sizes) {
const usage = nativeUsage(input);
const call = Object.freeze(saved(usage));
const before = structuredClone(call);
const expected = calculateCost(model, usage).total;
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
assert.deepEqual(call, before);
}
}
});
test("job capture preserves individual tier sizes and one-hour writes; old tier aggregates are unknown", () => {
const model = models.find((m) => m.cost.tiers?.length)!;
assert.ok(model);
const id = `${model.provider}:${model.id}`;
const threshold = model.cost.tiers![0]!.inputTokensAbove;
const first = nativeUsage(Math.ceil(threshold / 2));
const second = nativeUsage(Math.ceil(threshold / 2));
const total = accumulateJobUsage(accumulateJobUsage(undefined, id, piUsageToStep(first)), id, piUsageToStep(second));
assert.equal(total.calls?.length, 2);
assert.equal(total.cacheWrite1hTokens, 1000);
assert.equal(total.lastStep?.cacheWrite1hTokens, 500);
assert.equal(jobCost({ usage: total }), calculateCost(model, first).total + calculateCost(model, second).total);
assert.equal(jobCost({ usage: { ...total, calls: undefined } }), null);
const legacyOne = { ...total, ...saved(first), calls: undefined, steps: 1 };
assert.equal(jobCost({ usage: legacyOne }), calculateCost(model, first).total);
const resumedLegacy = accumulateJobUsage({ ...total, calls: undefined }, id, piUsageToStep(first));
assert.equal(resumedLegacy.calls, undefined, "never pretend the missing old calls were reconstructed");
});
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pricing-transport-"));
process.env.LH_DATA_DIR = scratch;
process.env.LH_AUTH_FILE = path.join(scratch, "empty-auth.json");
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
const { chatStore } = await import("../src/store/chats.ts");
const { GET } = await import("../app/api/chats/[id]/route.ts");
const { sessionCost } = await import("../src/server/pricing.ts");
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
test("chat GET supplies derived Pi costs without persisting dollars or changing native history", async () => {
const record = chatStore.createChat("price projection fixture");
chatStore.saveMessages(record.id, [{ id: "fixture-input", role: "user", content: "synthetic", timestamp: 1 }]);
const step = { inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000, cacheWriteTokens: 0, cacheWrite1hTokens: 0, reasoningTokens: 500 };
for (const [id, model] of [["api", "openrouter:openai/gpt-6.1-sol"], ["plan", "chatgpt:fixture"], ["unknown", "missing:fixture"]]) {
chatStore.appendTurnUsage(record.id, { id: id!, model: model!, messageId: "fixture-input", at: "fixture", steps: [step] });
}
const file = path.join(scratch, "chats", `${record.id}.json`);
const before = fs.readFileSync(file, "utf8");
const response = await GET(new Request(`http://localhost/api/chats/${record.id}`), { params: Promise.resolve({ id: record.id }) });
assert.equal(response.status, 200);
const body = await response.json();
assert.deepEqual(body.messages, chatStore.getChat(record.id)!.messages);
assert.deepEqual(body.pricing, sessionCost(chatStore.getChat(record.id)!));
assert.equal(body.pricing.cost, 0.037);
assert.equal(body.pricing.subscription, 1);
assert.equal(body.pricing.unpriced, 1);
assert.deepEqual(body.pricing.turnCosts, { api: 0.037, plan: null, unknown: null });
assert.equal(fs.readFileSync(file, "utf8"), before, "read-time estimates must never rewrite the usage log");
});
+7 -5
View File
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx";
import { formatTokens, formatUsd } from "../components/usage-format.ts";
import { jobCost } from "../src/server/pricing.ts";
// The section's pure layer: folds, formatting, and blob order — then the SSR
// render (blobs, "—" doctrine, the abort affordance, the always-present
@@ -27,6 +28,7 @@ function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jo
updatedAt: new Date(NOW - 1000).toISOString(),
title: "Limits lesson",
contextLimit: null,
costUsd: jobCost({ usage: over.usage }),
...over,
};
}
@@ -57,7 +59,7 @@ describe("activity dock pure layer", () => {
assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped");
});
it("folds cost with jobCost; absent usage is null, never zero", () => {
it("uses the server's Pi price projection; absent pricing is null, never zero", () => {
const priced = job({
usage: {
model: "openrouter:z-ai/glm-5.3-flash",
@@ -70,10 +72,10 @@ describe("activity dock pure layer", () => {
},
});
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
// (pi-ai catalog prices — glm-5.3-flash cacheRead corrected
// 0.05 → 0.03 in the pi 1.0.2 catalog refresh)
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
// Pi-normalized fresh input is priced directly, never reduced by reads.
assert.equal(blob.cost, 0.00215);
const missingProjection = toBlobs({ jobs: [{ ...priced, costUsd: undefined }], extractions: [] })[0]!;
assert.equal(missingProjection.cost, null, "the browser must not invent a fallback price");
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
assert.equal(absent.cost, null);
});
+4 -3
View File
@@ -41,7 +41,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
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={() => {}} />);
assert.ok(html.includes('role="dialog"'));
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"]) {
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
}
// General is the landing section; the others are not current.
assert.ok(tagOf(html, "settings-nav-general").includes('aria-current="true"'));
// Providers is the first landing section; internal keys deep links survive.
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"'));
// Fetches don't run under SSR — the loading state IS the SSR state.
assert.ok(html.includes("Loading settings…"));
+15 -3
View File
@@ -1,7 +1,8 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { StatsBar } from "../components/stats-bar.tsx";
import { StatsBar, formatContextMeasurement } from "../components/stats-bar.tsx";
import { sessionCost } from "../src/server/pricing.ts";
import { catalogLimits } from "../components/model-picker.tsx";
import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts";
import { prettyModel } from "../components/usage-format.ts";
@@ -50,7 +51,7 @@ describe("stats bar", () => {
it("folds context + cost from the turns log (core/prices, no local math)", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[turn(), turn()]} />,
<StatsBar turns={[turn(), turn()]} contextTokens={11_000} pricing={sessionCost({ turns: [turn(), turn()] })} />,
);
// last turn's last call: 10k in + 1k out = 11k. The LIMIT is the
// live catalog's — and the bar doesn't hold the index yet (the
@@ -68,7 +69,7 @@ describe("stats bar", () => {
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[turn({ model: "nope/unknown" })]} />,
<StatsBar turns={[turn({ model: "nope/unknown" })]} contextTokens={11_000} pricing={sessionCost({ turns: [turn({ model: "nope/unknown" })] })} />,
);
assert.match(html, /data-testid="stats-cost">—</);
// unknown model: no catalog limit exists, but the footprint is
@@ -77,6 +78,17 @@ describe("stats bar", () => {
assert.match(html, /data-testid="stats-context">11k</);
});
it("unknown context is explicit; changing the selected limit preserves the measurement", () => {
assert.equal(formatContextMeasurement(null, 100_000), "? / 100k");
assert.equal(formatContextMeasurement(83_000, 100_000), "83k / 100k");
assert.equal(formatContextMeasurement(83_000, 200_000), "83k / 200k");
assert.equal(formatContextMeasurement(83_000, null), "83k");
const html = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[turn()]} contextTokens={null} />);
assert.match(html, /data-testid="stats-context">\?</);
const empty = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[]} />);
assert.match(empty, /data-testid="stats-context">\?</);
});
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
+46
View File
@@ -0,0 +1,46 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import test from "node:test";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { StatsBar } from "../components/stats-bar.tsx";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
(globalThis as Record<string, unknown>).React = React;
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 });
test("meter keeps its measured numerator across selected-model/catalog changes, and renders post-compaction unknown", async () => {
const originalFetch = globalThis.fetch;
let selected = "a";
const catalog: ModelCatalogResponse = {
models: ["a", "b"].map((id, i) => ({ id, provider: "fixture", name: id, input: ["text"], reasoning: false, supportedLevels: ["off"], maxTokens: 1000, contextWindow: 100_000 * (i + 1), cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } })),
providers: [], defaults: null, slots: { strong: null, cheap: null, vision: null }, rolePins: {} as ModelCatalogResponse["rolePins"],
};
globalThis.fetch = async (input) => {
if (input === "/api/models") return Response.json(catalog);
if (input === "/api/settings") return Response.json({ settings: { models: {} } });
if (input === "/api/chats/fixture") return Response.json({ settings: { model: { provider: "fixture", id: selected }, thinkingLevel: "off" } });
throw new Error(`unexpected fixture request ${String(input)}`);
};
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const turn: TurnUsage = { id: "summary", messageId: "marker", at: "fixture", model: "fixture:a", steps: [{ inputTokens: 4300, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }] };
const pricing = { cost: 0.003, unpriced: 0, turnCosts: { summary: 0.003 } };
try {
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={83_000} pricing={pricing} />));
const context = () => host.querySelector('[data-testid="stats-context"]')?.textContent;
assert.equal(context(), "83k / 100k", "summarizer's 4.3k money log is not the meter source");
selected = "b";
await act(async () => notifySettingsSaved("models"));
assert.equal(context(), "83k / 200k", "only denominator changes");
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={null} pricing={pricing} />));
assert.equal(context(), "? / 200k");
assert.equal(host.querySelector('[data-testid="stats-cost"]')?.textContent, "$0.0030", "summary spending is still visible");
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={72_000} pricing={pricing} />));
assert.equal(context(), "72k / 200k");
} finally {
await act(async () => root.unmount()); host.remove(); globalThis.fetch = originalFetch;
}
});
+69 -262
View File
@@ -1,307 +1,114 @@
/**
* Usage-accounting tests — the money channel's pure helpers (core/prices.ts)
* and the ChatStore usage-log seam (appendTurnUsage).
*
* Pure helper tests are pure. The store test uses the real fs chatStore with
* a fixture chat, cleaned up in after().
*/
import assert from "node:assert/strict";
import test from "node:test";
import * as fs from "node:fs";
import { chatStore } from "../src/store/chats.ts";
import { graphStore } from "../src/store/graph.ts";
import { PATHS, SOURCES_FILE } from "../config.ts";
import { callCost, contextWindow, jobCost, sessionCost, sumSteps, turnCost } from "../src/core/prices.ts";
import { callCost, jobCost, sessionCost, turnCost } from "../src/server/pricing.ts";
import { sumSteps } from "../src/core/prices.ts";
import type { CallUsage, ExtractionRunUsage, JobRecord, TurnUsage } from "../src/core/contracts.ts";
const call = (over: Partial<CallUsage> = {}): CallUsage => ({
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
...over,
inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined,
cacheWriteTokens: undefined, reasoningTokens: undefined, ...over,
});
test("sumSteps folds steps; absent stays absent until a value appears", () => {
const sum = sumSteps([
call({ inputTokens: 100, outputTokens: 20 }),
call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 }),
]);
test("sumSteps: absent stays absent; unknown long-cache detail stays unknown", () => {
const sum = sumSteps([call({ inputTokens: 100, outputTokens: 20 }), call({ inputTokens: 50, outputTokens: 10, cacheReadTokens: 30 })]);
assert.equal(sum.inputTokens, 150);
assert.equal(sum.outputTokens, 30);
assert.equal(sum.cacheReadTokens, 30);
assert.equal(sum.cacheWriteTokens, undefined); // never reported → stays undefined
// all-absent → all-undefined (helper defaults input/output to 0, so
// explicit undefined here)
const empty = sumSteps([call({ inputTokens: undefined, outputTokens: undefined }), call({ inputTokens: undefined, outputTokens: undefined })]);
assert.equal(empty.inputTokens, undefined);
assert.equal(sum.cacheWriteTokens, undefined);
assert.equal(sumSteps([call({ inputTokens: undefined, outputTokens: undefined })]).inputTokens, undefined);
assert.equal(sumSteps([call({ cacheWriteTokens: 10 }), call({ cacheWriteTokens: 20, cacheWrite1hTokens: 5 })]).cacheWrite1hTokens, undefined);
});
test("callCost: glm-5.3-flash math — fresh input, cached read, output", () => {
test("callCost prices normalized fresh input directly, without subtracting caches again", () => {
const cost = callCost(call({ inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 10_000 }), "openrouter:z-ai/glm-5.3-flash");
assert.ok(cost !== null);
// fresh 90k @ $0.15/M + cached 10k @ $0.03/M + output 20k @ $0.5/M
// (pi-ai catalog prices — regenerated tables, scripts/sync-pricing;
// glm-5.3-flash cacheRead corrected 0.05 → 0.03 in pi 1.0.2)
assert.ok(Math.abs(cost! - (0.0135 + 0.0003 + 0.01)) < 1e-9);
assert.ok(Math.abs(cost - (0.015 + 0.0003 + 0.01)) < 1e-9);
assert.equal(callCost(call({ inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000 }), "openrouter:openai/gpt-6.1-sol"), 0.037);
});
test("callCost: cache writes bill at the cacheWrite rate", () => {
const cost = callCost(
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
"anthropic:claude-haiku-4-5",
);
assert.ok(cost !== null);
// Native 1.4 catalog: fresh 1M @ $1/M + cache write 500k @ $1.25/M.
assert.ok(Math.abs(cost! - (1 + 0.625)) < 1e-9);
test("cache writes: precise short/one-hour costs and honest unknown historical splits", () => {
const usage = call({ inputTokens: 1_000_000, cacheWriteTokens: 500_000, cacheWrite1hTokens: 0 });
assert.equal(callCost(usage, "anthropic:claude-haiku-4-5"), 1.625);
assert.equal(callCost({ ...usage, cacheWrite1hTokens: 200_000 }, "anthropic:claude-haiku-4-5"), 1.775);
assert.equal(callCost({ ...usage, cacheWrite1hTokens: undefined }, "anthropic:claude-haiku-4-5"), null);
});
test("callCost: unknown model and absent fields are null, never zero", () => {
assert.equal(callCost(call({ inputTokens: 100, outputTokens: 5 }), "not/a-model"), null);
assert.equal(callCost(call({ inputTokens: 100, outputTokens: undefined }), "openrouter:z-ai/glm-5.3-flash"), null);
test("unknown model, absent or invalid required usage is null, never fake zero", () => {
assert.equal(callCost(call(), "not/a-model"), null);
assert.equal(callCost(call({ outputTokens: undefined }), "openai:gpt-5"), null);
for (const value of [-1, NaN, Infinity]) assert.equal(callCost(call({ inputTokens: value }), "openai:gpt-5"), null);
});
test("turnCost sums steps; sessionCost reports unpriced turns", () => {
const turn: TurnUsage = {
id: "t1",
messageId: "m1",
at: "2026-09-09T00:00:00Z",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })],
};
assert.ok(turnCost(turn) !== null);
const totals = sessionCost({
turns: [turn, { id: "t2", messageId: "m2", at: "x", model: "not/a-model", steps: [call({ inputTokens: 1, outputTokens: 1 })] }],
});
test("turn/session costs sum calls and count unpriced separately", () => {
const turn: TurnUsage = { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })] };
const totals = sessionCost({ turns: [turn, { ...turn, id: "t2", model: "not/a-model" }] });
assert.equal(totals.unpriced, 1);
assert.ok(totals.cost > 0);
assert.equal(totals.cost, turnCost(turn));
assert.equal(totals.turnCosts.t1, totals.cost);
assert.equal(totals.turnCosts.t2, null);
});
test("contextWindow: last call's footprint against the live index's limit", () => {
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
const cw = contextWindow(
{
turns: [
{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] },
{ id: "t2", messageId: "m2", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 90_000, outputTokens: 10_000 })] },
],
},
limits,
);
assert.ok(cw);
assert.equal(cw.model, "openrouter:z-ai/glm-5.3-flash");
assert.equal(cw.usedTokens, 100_000);
assert.equal(cw.limit, 1_048_576);
assert.equal(contextWindow({ turns: [] }, limits), null);
});
test("contextWindow NUMERATOR: cache tokens count (owner audit, 2026-09-23)", () => {
// The audit's live case: a cache-heavy session read 40k on the meter
// while the real footprint was ~190k — provider `input` EXCLUDES the
// cached portion, so input+output alone is wildly low. The fold is
// pi's totalTokens semantics: all four fields.
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
const cw = contextWindow(
{
turns: [
{
id: "t1",
messageId: "m1",
at: "x",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 40_000, outputTokens: 1_000, cacheReadTokens: 145_000, cacheWriteTokens: 4_000 })],
},
],
},
limits,
);
assert.ok(cw);
assert.equal(cw.usedTokens, 40_000 + 1_000 + 145_000 + 4_000, "input + output + cacheRead + cacheWrite");
// Cache fields absent (non-caching providers): the fold is unchanged.
const plain = contextWindow(
{ turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }] },
limits,
);
assert.ok(plain);
assert.equal(plain.usedTokens, 10_000);
});
test("contextWindow DENOMINATOR: the live index is the ONLY source — no fallback (owner amendment, 2026-09-23)", () => {
const turns = [
{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 10_000, outputTokens: 1_000 })] },
];
const live = new Map([["openrouter:z-ai/glm-5.3-flash", 65_536]]);
const cw = contextWindow({ turns }, live);
assert.ok(cw);
assert.equal(cw.limit, 65_536, "pi's catalog, via the index, is the one source");
// No index at all (a client that hasn't fetched the catalog): the
// meter renders usage-only — limit null, never a guessed window.
const noIndex = contextWindow({ turns });
assert.ok(noIndex);
assert.equal(noIndex.usedTokens, 11_000);
assert.equal(noIndex.limit, null);
const emptyIndex = contextWindow({ turns }, new Map<string, number>());
assert.ok(emptyIndex);
assert.equal(emptyIndex.limit, null, "an empty index (failed fetch) is usage-only too");
// A model missing from the live catalog (historical session, package
// update removed it): usage-only, never a fake window.
const missing = contextWindow({ turns: [{ ...turns[0]!, model: "nope/gone-model" }] }, live);
assert.ok(missing);
assert.equal(missing.limit, null);
assert.equal(missing.usedTokens, 11_000, "the footprint still reads honestly");
});
test("contextWindow ride-along: a zeroed/aborted last step falls back to the previous step", () => {
// An aborted last call reports zeroed usage — that is not the session
// footprint. The honest reading is the previous step's usage.
const limits = new Map([["openrouter:z-ai/glm-5.3-flash", 1_048_576]]);
const cw = contextWindow(
{
turns: [
{
id: "t1",
messageId: "m1",
at: "x",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [
call({ inputTokens: 80_000, outputTokens: 5_000 }),
call({ inputTokens: 0, outputTokens: 0 }), // aborted — zeroed
],
},
],
},
limits,
);
assert.ok(cw);
assert.equal(cw.usedTokens, 85_000, "the previous step's footprint, not a bogus tiny meter");
// ALL steps zeroed (a first-call abort): no reading at all — null.
assert.equal(
contextWindow(
{ turns: [{ id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 0, outputTokens: 0 })] }] },
limits,
),
null,
);
});
test("jobCost: null without usage, computed with it", () => {
test("legacy flat-rate jobs remain priceable; missing usage stays absent", () => {
assert.equal(jobCost({}), null);
const job: Pick<JobRecord, "usage"> = {
usage: {
model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 1_000_000,
outputTokens: 100_000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
steps: 4,
},
};
assert.ok(jobCost(job) !== null);
const job: Pick<JobRecord, "usage"> = { usage: { model: "openrouter:z-ai/glm-5.3-flash", inputTokens: 1_000_000, outputTokens: 100_000, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, steps: 4 } };
assert.ok(jobCost(job)! > 0);
});
// ─── the store seam ──────────────────────────────────────────────────────────
test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same message) all kept, survives saveMessages", () => {
const record = chatStore.createChat("usage test chat");
const entry: TurnUsage = {
id: "turn_1",
messageId: "msg_a",
at: "2026-09-09T00:00:00Z",
model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 100, outputTokens: 10 })],
};
chatStore.appendTurnUsage(record.id, entry);
chatStore.appendTurnUsage(record.id, entry); // replay (abort/retry) → no-op
// continuation turn: SAME response message (SDK coalesces), new turn id —
// usage must survive, not collapse into the first entry
chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" });
let reread = chatStore.getChat(record.id);
assert.equal(reread?.turns?.length, 2);
// saveMessages must not clobber the log (read-mutate-write record) —
// Wave B: a NATIVE message list (the record holds agent history).
chatStore.saveMessages(record.id, [
{ id: "msg_a", role: "user", content: "native", timestamp: Date.now() } as never,
]);
reread = chatStore.getChat(record.id);
assert.equal(reread?.turns?.length, 2);
test("appendTurnUsage: idempotent per turn; continuations survive saveMessages; list omits log", () => {
const record = chatStore.createChat("usage fixture");
try {
const entry: TurnUsage = { id: "turn_1", messageId: "msg_a", at: "x", model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10, cacheWrite1hTokens: 0 })] };
chatStore.appendTurnUsage(record.id, entry);
chatStore.appendTurnUsage(record.id, entry);
chatStore.appendTurnUsage(record.id, { ...entry, id: "turn_2" });
assert.equal(chatStore.getChat(record.id)?.turns?.length, 2);
chatStore.saveMessages(record.id, [{ id: "msg_a", role: "user", content: "fixture", timestamp: 1 }]);
assert.deepEqual(chatStore.getChat(record.id)?.turns?.[0]?.steps, JSON.parse(JSON.stringify(entry.steps)));
assert.equal(chatStore.getChat(record.id)?.turns?.length, 2);
assert.equal("turns" in chatStore.listChats().find((c) => c.id === record.id)!, false);
} finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); }
});
// and the list payload must NOT leak turns
const listed = chatStore.listChats().find((c) => c.id === record.id);
assert.ok(listed);
assert.equal("turns" in listed, false);
fs.unlinkSync(`${PATHS.chats}/${record.id}.json`);
const runEntry = (startedAt: number): ExtractionRunUsage => ({
startedAt, at: "2026-09-09T00:00:00Z", durationMs: 42_000,
stats: { nodes: 1, chains: 2 }, stages: {
extractor: { model: "openai:gpt-5", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openai:gpt-5", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
},
});
test("appendExtractionUsage: appends, idempotent per startedAt, not in list payload", () => {
const record = chatStore.createChat("extraction usage test chat");
const runEntry = (startedAt: number): ExtractionRunUsage => ({
startedAt,
at: "2026-09-09T00:00:00Z",
durationMs: 42_000,
stats: { nodes: 1, chains: 2 },
stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
},
});
chatStore.appendExtractionUsage(record.id, runEntry(1));
chatStore.appendExtractionUsage(record.id, runEntry(1)); // same run appended twice → no-op
chatStore.appendExtractionUsage(record.id, runEntry(2)); // a retried run is a NEW spend
let reread = chatStore.getChat(record.id);
assert.equal(reread?.extractions?.length, 2);
// failed runs (stats absent) persist with usage — money was spent
chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined });
reread = chatStore.getChat(record.id);
assert.equal(reread?.extractions?.length, 3);
assert.equal(reread?.extractions?.at(-1)?.stats, undefined);
// the list payload must NOT leak the extractions log
const listed = chatStore.listChats().find((c) => c.id === record.id);
assert.ok(listed);
assert.equal("extractions" in listed, false);
fs.unlinkSync(`${PATHS.chats}/${record.id}.json`);
test("appendExtractionUsage: idempotent; failed runs persist; list omits log", () => {
const record = chatStore.createChat("extraction usage fixture");
try {
chatStore.appendExtractionUsage(record.id, runEntry(1));
chatStore.appendExtractionUsage(record.id, runEntry(1));
chatStore.appendExtractionUsage(record.id, runEntry(2));
chatStore.appendExtractionUsage(record.id, { ...runEntry(3), stats: undefined });
assert.equal(chatStore.getChat(record.id)?.extractions?.length, 3);
assert.equal(chatStore.getChat(record.id)?.extractions?.at(-1)?.stats, undefined);
assert.equal("extractions" in chatStore.listChats().find((c) => c.id === record.id)!, false);
} finally { fs.unlinkSync(`${PATHS.chats}/${record.id}.json`); }
});
test("appendSourceUsage: appends to the source record, idempotent per startedAt, upsert keeps history", () => {
// The real fs graphStore — sources.json backed up and restored around
// this test (same discipline as the chat fixture files).
test("appendSourceUsage: idempotent; reimport keeps history; absent source is a no-op", () => {
fs.mkdirSync(PATHS.graph, { recursive: true });
let bak: string | undefined;
if (fs.existsSync(SOURCES_FILE)) bak = `${SOURCES_FILE}.w5test.bak`;
if (bak) fs.renameSync(SOURCES_FILE, bak);
const backup = fs.existsSync(SOURCES_FILE) ? fs.readFileSync(SOURCES_FILE) : undefined;
try {
const runEntry = (startedAt: number): ExtractionRunUsage => ({
startedAt,
at: "2026-09-13T00:00:00Z",
durationMs: 42_000,
stats: { nodes: 1, chains: 2 },
stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
},
});
const id = "src_usagetest01";
graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-13T00:00:00Z" });
graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-13T00:00:00Z" });
graphStore.appendSourceUsage(id, runEntry(1));
graphStore.appendSourceUsage(id, runEntry(1)); // same run appended twice → no-op
graphStore.appendSourceUsage(id, runEntry(2)); // a retried run is a NEW spend
graphStore.appendSourceUsage(id, runEntry(1));
graphStore.appendSourceUsage(id, runEntry(2));
assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2);
// An upsert (re-import) KEEPS the usage history.
graphStore.upsertSource({ id, filePath: "/vault/usagetest.md", importedAt: "2026-09-14T00:00:00Z" });
graphStore.upsertSource({ id, filePath: "/fixture/notes.md", importedAt: "2026-09-14T00:00:00Z" });
assert.equal(graphStore.readSources().find((s) => s.id === id)?.extractions?.length, 2);
// Unknown ids are a silent no-op (nothing to bill against).
graphStore.appendSourceUsage("src_nope", runEntry(1));
graphStore.appendSourceUsage("src_missing", runEntry(1));
} finally {
fs.rmSync(SOURCES_FILE, { force: true });
if (bak) fs.renameSync(bak, SOURCES_FILE);
if (backup) fs.writeFileSync(SOURCES_FILE, backup);
else fs.rmSync(SOURCES_FILE, { force: true });
}
});
+2 -1
View File
@@ -76,7 +76,8 @@ describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
const html = renderToStaticMarkup(
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.doesNotMatch(html, /First, add|keys unlock|—/);
assert.ok(html.includes('data-testid="setup-key-provider"'));