Merge remote-tracking branch 'origin/main' into add-claude-code-support

# Conflicts:
#	package-lock.json
#	src/agent/models.ts
This commit is contained in:
Ivanko1608
2026-10-25 02:08:15 +04:00
107 changed files with 5198 additions and 2624 deletions
+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");
});
+42
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,
@@ -610,6 +615,43 @@ describe("research runner (async protocol §3)", () => {
assert.deepEqual(search.queries, ["sigma.js latest version (5)"]);
});
it("a keyless fetch-only researcher still completes and delivers a sourced job result", async () => {
const url = "https://example.com/supplied-source";
const brief = `## Summary\nVerified supplied source. [Source](${url})\n\n## Sources\n- Source (${url})`;
const input = { question: `Verify ${url}` };
const chat = chatStore.createChat("Test: keyless fetch-only research");
trackChat(chat.id);
chatStore.saveMessages(chat.id, nativeToolMessages("verify supplied URL", "research", "call-keyless", input));
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-keyless" });
trackJob(job.id);
const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver, searchProvider: () => null });
const fetched: string[] = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (async (source) => {
fetched.push(String(source));
assert.equal(String(source), url);
return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } });
}) as typeof fetch;
researchModelOverride.current = fauxEngine([
fauxAssistantMessage([fauxToolCall("web_fetch", { url }, { id: "keyless-fetch" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText(brief)]),
]);
try {
await runner.runJob(job.id);
} finally {
researchModelOverride.current = undefined;
globalThis.fetch = realFetch;
}
assert.deepEqual(fetched, [url]);
const done = jobStore.getJob(job.id)!;
assert.equal(done.status, "done");
assert.equal((done.result as ResearchResult).brief, brief);
assert.ok((done.result as ResearchResult).sources.some((s) => s.url === url));
assert.equal(delivery.steers.length, 1);
assert.equal(isSubagentFailure("research", done.result as ResearchResult), false);
});
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
const chat = chatStore.createChat("Test: runner failure");
trackChat(chat.id);
+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"] },
+5 -4
View File
@@ -208,14 +208,15 @@ describe("the practice agent", () => {
const jobs = { chatId: "c1", jobStore: null as never, spawn: (() => {}) as never, searchAvailable: () => false };
const graph = { chatStore: null as never, graphStore: null as never };
const tools = practiceTools({ ...ctxOf([]), jobs, graph });
// research honestly absent (no search engine live); pdf always; the
// prober's two map readers behind the settings gate.
// Both jobs remain available keylessly; the prober's two map
// readers stay behind their existing settings gate. Practice does
// not apply the teacher/prober user tool-toggle filter.
assert.deepEqual(
Object.keys(tools).sort(),
["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read"],
["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read", "research"],
);
const withSearch = practiceTools({ ...ctxOf([]), jobs: { ...jobs, searchAvailable: () => true }, graph });
assert.ok(withSearch.research, "a live engine adds research");
assert.ok(withSearch.research, "a live engine also keeps research");
});
it("constructs with exactly the two practice tools", () => {
-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");
});
+42
View File
@@ -0,0 +1,42 @@
/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
const script = fileURLToPath(new URL("../scripts/smoke-provider-runtime.mjs", import.meta.url));
const checkout = fileURLToPath(new URL("..", import.meta.url));
const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 });
describe("offline native Pi Google initialization gate", () => {
it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => {
// This is an unbundled positive control, NOT proof of standalone completeness.
const result = run(checkout);
assert.equal(result.status, 0, String(result.stderr));
for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`));
assert.match(String(result.stdout), /zero network attempts/);
});
it("rejects a missing Pi entry instead of treating any error as success", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-missing-"));
try {
const result = run(root);
assert.equal(result.status, 1);
assert.doesNotMatch(String(result.stdout), /initialization passed/);
assert.match(String(result.stderr), /MODULE_NOT_FOUND|ENOENT/);
assert.deepEqual(fs.readdirSync(root), [], "gate modified the payload");
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
it("rejects an otherwise complete SDK outside the payload (no host fallback)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-escape-"));
try {
fs.symlinkSync(path.join(checkout, "node_modules"), path.join(root, "node_modules"), process.platform === "win32" ? "junction" : "dir");
const result = run(root);
assert.equal(result.status, 1);
assert.match(String(result.stderr), /dependency escaped standalone payload/);
assert.doesNotMatch(String(result.stdout), /initialization passed/);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
});
+62
View File
@@ -0,0 +1,62 @@
/** JS matcher + config-policy regressions; native Turbopack needs the real payload gate. */
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { describe, it } from "node:test";
import nextConfig from "../next.config.ts";
const require = createRequire(import.meta.url);
// Use Next's actual bundled matcher, not a different glob implementation.
const picomatch = require("next/dist/compiled/picomatch");
const { makeIgnoreFn } = require("next/dist/build/collect-build-traces");
const patterns = nextConfig.outputFileTracingExcludes!["*"];
describe("standalone JavaScript tracing exclusions and native workaround policy", () => {
for (const windows of [false, true]) {
const root = windows ? "C:\\build\\learnharness" : "/build/learnharness";
const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative);
const ignored = picomatch(patterns, { contains: true, dot: true, windows });
it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => {
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) {
assert.equal(ignored(file(relative)), true, relative);
}
// The explicit includes remain necessary: this raw ignore still matches SDK data.
assert.equal(ignored(file("node_modules/@earendil-works/pi-ai/dist/providers/data/models.json")), true);
assert.ok(nextConfig.outputFileTracingIncludes!["*"]!.includes("node_modules/@earendil-works/pi-ai/dist/providers/data/**/*"));
});
it(`does not confuse dependency suffixes with data directories (${windows ? "Windows" : "POSIX"})`, () => {
for (const relative of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/form-data/lib/form_data.js", "node_modules/@swc/helpers/_/_ts_metadata.js", "node_modules/@swc/helpers/_/_ts_metadata/index.js", "node_modules/somedata/runtime.js"]) {
assert.equal(ignored(file(relative)), false, relative);
assert.equal(picomatch("data/**/*", { contains: true, dot: true, windows })(file("node_modules/gcp-metadata/build/src/index.js")), true, "old pattern must reproduce the bug");
}
});
}
it("uses the installed Next JavaScript early-ignore function on the host platform", () => {
const root = process.cwd();
const ignore = makeIgnoreFn(root, patterns);
assert.equal(ignore(`${root}/data/private.json`), true);
assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false);
});
it("preserves all secret exclusions exactly", () => {
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]);
});
it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => {
assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [
"node_modules/@puppeteer/browsers/lib/browser-data/**/*.js",
"node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*",
"node_modules/@napi-rs/canvas-*/**/*",
"node_modules/@resvg/resvg-js-*/**/*",
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
"node_modules/gcp-metadata/**/*",
"node_modules/json-bigint/**/*",
"node_modules/bignumber.js/**/*",
]);
});
it("limits the workaround to its three packages, never learner data or unrelated dependencies", () => {
const closure = nextConfig.outputFileTracingIncludes!["*"]!.slice(-3);
const included = picomatch(closure, { dot: true });
for (const file of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/json-bigint/lib/parse.js", "node_modules/bignumber.js/bignumber.js"]) assert.equal(included(file), true, file);
for (const file of ["data/private.json", "nested/data/private.json", "auth.json", ".env.local", "node_modules/unrelated/runtime.js", "node_modules/gcp-metadata-lookalike/index.js"]) assert.equal(included(file), false, file);
});
});
+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");
+70
View File
@@ -0,0 +1,70 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { it } from "node:test";
import { parseHTML } from "linkedom";
import { AgentSection } from "../components/settings/agent-section.tsx";
import type { AgentSettings } from "../src/core/index.ts";
it("keyless settings keep Research interactive while search-engine setup retains its own disabled state", async () => {
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
const globals = globalThis as Record<string, unknown>;
const previous = new Map<string, PropertyDescriptor | undefined>();
const reason = "No search engine is configured.";
const bodies: Record<string, unknown> = {
"/api/search": { available: false, engine: null, reason },
"/api/keys": { keys: [{ id: "TAVILY_API_KEY", present: false }, { id: "BRAVE_API_KEY", present: false }] },
"/api/models": { models: [], slots: { strong: null, cheap: null, vision: null } },
"/api/settings/prompts": { prober: [], teacher: [] },
};
for (const [key, value] of Object.entries({
window: dom.window,
document: dom.document,
navigator: globals.navigator ?? { userAgent: "", platform: "" },
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
fetch: async (url: string) => {
assert.ok(url in bodies, `unexpected request: ${url}`);
return new Response(JSON.stringify(bodies[url]));
},
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const { createRoot } = await import("react-dom/client");
const root = createRoot(document.getElementById("root")!);
let written: AgentSettings | undefined;
try {
await act(async () => {
root.render(<AgentSection
agent={{ tools: { research: true } }}
models={undefined}
graph={undefined}
compaction={undefined}
onSectionChange={(next) => { written = next; }}
onModelsChange={() => {}}
onCompactionChange={() => {}}
/>);
});
const row = document.querySelector<HTMLElement>('[data-testid="tool-row-research"]')!;
const toggle = row.querySelector<HTMLButtonElement>('[data-testid="toggle-tool-research"]')!;
assert.ok(toggle);
assert.equal(toggle.hasAttribute("disabled"), false);
assert.equal(toggle.getAttribute("aria-checked"), "true");
assert.ok(!row.className.includes("opacity-40"));
assert.ok(!row.textContent?.includes(reason), "search setup hint is no longer a research-disable hint");
act(() => { toggle.click(); });
assert.equal(written?.tools?.research, false, "the keyless toggle emits the same stored boolean");
const engine = document.querySelector<HTMLButtonElement>('[data-testid="search-engine-trigger"]')!;
assert.ok(engine.className.includes("cursor-not-allowed"), "search engine preference retains its own key gate");
act(() => { engine.click(); });
assert.equal(document.querySelector('[data-testid="search-engine-menu"]'), null, "keyless engine preference does not open");
assert.ok(document.querySelector('[data-testid="search-engine-row"]')?.textContent?.includes(reason));
} finally {
act(() => { root.unmount(); });
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globals[key];
}
}
});
-16
View File
@@ -15,7 +15,6 @@ import {
composeSlotModel,
composeTool,
composeQuizMechanics,
researchToolUnavailable,
sectionedSegments,
} from "../components/settings/agent-section.tsx";
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
@@ -328,21 +327,6 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
});
});
describe("researchToolUnavailable — the row grays on the registry's own signal", () => {
it("research grays exactly when the live search status says unavailable", () => {
// the SAME /api/search signal chat-handler's searchAvailable uses —
// the row and the registry can never disagree (Teacher flag 2026-09-22)
assert.equal(researchToolUnavailable("research", { available: false }), true);
assert.equal(researchToolUnavailable("research", { available: true }), false);
// loading/failed fetch grays nothing (never a false-negative row)
assert.equal(researchToolUnavailable("research", null), false);
// only research — the other tools never touch the search port
for (const id of ["demo", "visual", "pdf"]) {
assert.equal(researchToolUnavailable(id, { available: false }), false);
}
});
});
describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
// The server contract: BOTH per-agent lists include the shared
// segments (same paths) — the view must list them ONCE.
+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 });
}
});
+124 -31
View File
@@ -1,20 +1,14 @@
/**
* Search rewiring (search port wave, 2026-09-20) — the web_search tool's
* port injection + the research dispatch tool's registry-level drop.
*
* Google CSE is dead: the tool runs on the injected search port
* (core/search.ts), the runner threads the seam (searchProvider — covered
* end-to-end in jobs.test.ts), and jobTools drops the research tool when
* ctx.searchAvailable reports no live engine (owner decision: registry
* drop, no doomed dispatch, no silent fallback).
*
/** Keyless research availability + the injected search port.
* No network, no shared data files — the jobStore fake below is in-memory.
* A missing search key is a recoverable tool result, not a dispatch gate.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createWebTools } from "../src/agent/tools/web.ts";
import { jobTools } from "../src/agent/tools/jobs.ts";
import { runResearcherAgent } from "../src/agent/subagents/researcher.ts";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import type { JobStore } from "../src/core/ports.ts";
import type { JobRecord } from "../src/core/contracts.ts";
import type { SearchHit, SearchProvider } from "../src/core/search.ts";
@@ -31,9 +25,13 @@ function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/",
describe("web_search on the injected port (web.ts)", () => {
it("null provider → the honest unavailability string, never a crash", async () => {
const { web_search } = createWebTools(() => null);
assert.ok(web_search, "web_search is still exposed without a provider");
const out = await web_search!.execute!("c1", { query: "anything" });
assert.match(String(out.details), /not available: no search engine is configured/);
assert.match(String(out.details), /Tavily or Brave/);
const missing = "web_search is not available: no search engine is configured. " +
"Set a Tavily or Brave search API key in settings. Report in your brief that web search is unavailable.";
assert.equal(out.details, missing);
assert.deepEqual(out.content, [{ type: "text", text: missing }]);
assert.notEqual(out.isError, true, "missing keys remain ordinary recoverable text, not a native error");
});
it("a live provider returns its hits (count defaults to 5)", async () => {
@@ -110,11 +108,86 @@ describe("web_search on the injected port (web.ts)", () => {
});
});
/** Minimal in-memory JobStore — the drop tests never touch the fs. */
describe("keyless researcher loop", () => {
it("sends the exact approved missing-credentials guidance in the researcher system prompt", async () => {
const approved = "If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources.";
const faux = createFauxCore({});
faux.setResponses([(context) => {
const system = context.messages.find((m) => m.role === "system");
assert.ok(system && system.role === "system");
assert.equal(typeof system.content, "string");
const prompt = system.content as string;
assert.ok(prompt.split("\n\n").includes(approved), "approved paragraph is passed verbatim");
assert.match(prompt, /Break the question into 2-4 searchable facets/);
assert.match(prompt, /## Gaps/);
assert.ok(system.toolsAdded?.some((t) => t.name === "web_search"));
assert.ok(system.toolsAdded?.some((t) => t.name === "web_fetch"));
return fauxAssistantMessage([fauxText("Prompt verified; no model behavior inferred.")]);
}]);
assert.equal(await runResearcherAgent("Check the prompt only", {
engine: { model: faux.getModel(), streamFn: faux.streamSimple },
}), "Prompt verified; no model behavior inferred.");
});
// These scripts prove tool/loop mechanics, not real-model compliance.
for (const searchFirst of [true, false]) {
it(searchFirst ? "recovers from missing search keys by fetching a supplied URL" : "fetch-only research needs no search credentials", async () => {
const url = "https://example.com/supplied-source";
const brief = `## Summary\nVerified supplied source. [Source](${url})`;
const steps: FauxResponseStep[] = [];
const fetchCall = () => fauxAssistantMessage(
[fauxToolCall("web_fetch", { url }, { id: "fetch1" })], { stopReason: "toolUse" },
);
if (searchFirst) {
steps.push(
() => {
return fauxAssistantMessage(
[fauxToolCall("web_search", { query: "supplied source" }, { id: "search1" })], { stopReason: "toolUse" },
);
},
(context) => {
const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "search1");
assert.ok(result && result.role === "toolResult");
assert.equal(result.isError, false, "missing keys do not abort the native loop");
assert.match(JSON.stringify(result.content), /no search engine is configured/);
return fetchCall();
},
);
} else steps.push(fetchCall());
steps.push((context) => {
const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "fetch1");
assert.ok(result && result.role === "toolResult");
assert.equal(result.isError, false);
assert.match(JSON.stringify(result.content), /Synthetic verified source content/);
return fauxAssistantMessage([fauxText(brief)]);
});
const faux = createFauxCore({});
faux.setResponses(steps);
const realFetch = globalThis.fetch;
const fetched: string[] = [];
globalThis.fetch = (async (input) => {
fetched.push(String(input));
assert.equal(String(input), url, "only the supplied URL is fetched");
return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } });
}) as typeof fetch;
try {
assert.equal(await runResearcherAgent(`Verify ${url}`, {
engine: { model: faux.getModel(), streamFn: faux.streamSimple },
// No search seam: this is the keyless backstop, not a search stub.
}), brief);
assert.deepEqual(fetched, [url]);
} finally {
globalThis.fetch = realFetch;
}
});
}
});
/** Minimal in-memory JobStore — registry tests never touch the fs. */
function fakeJobStore(): JobStore {
const jobs = new Map<string, JobRecord>();
return {
createJob(init) {
createJob(init) {
const job: JobRecord = {
id: `job_${jobs.size + 1}`,
kind: init.kind,
@@ -149,13 +222,28 @@ function fakeCtx(available?: () => boolean) {
};
}
describe("the research registry drop (jobTools)", () => {
it("searchUnavailable: false → research drops from the registry (the other job tools stay)", () => {
describe("keyless research dispatch (jobTools)", () => {
it("searchAvailable: false → research remains in the registry", () => {
const tools = jobTools(fakeCtx(() => false));
assert.equal("research" in tools, false, "no research tool — no doomed dispatch");
assert.ok(tools.research, "research can fetch supplied URLs without a search provider");
assert.ok(tools.demo && tools.visual && tools.pdf, "the search-less server keeps its other sub-agents");
});
it("keyless dispatch preserves the async job acknowledgement and spawn contract", async () => {
const ctx = fakeCtx(() => false);
const spawned: Array<{ job: JobRecord; input: unknown }> = [];
const tools = jobTools({ ...ctx, spawn: (job, input) => { spawned.push({ job, input }); } });
const input = { question: "Verify the supplied URL", context: "https://example.com/source" };
const out = await tools.research!.execute!("keyless-call", input);
assert.match(String(out.details), /^Dispatched \(job job_1\)\./);
assert.match(String(out.details), /fire-and-forget/);
assert.equal(spawned.length, 1);
assert.equal(spawned[0]!.job.kind, "research");
assert.equal(spawned[0]!.job.toolCallId, "keyless-call");
assert.equal(spawned[0]!.job.status, "running");
assert.deepEqual(spawned[0]!.input, input);
});
it("searchAvailable: true → research present", () => {
const tools = jobTools(fakeCtx(() => true));
assert.ok(tools.research, "a keyed server exposes research");
@@ -167,12 +255,8 @@ describe("the research registry drop (jobTools)", () => {
});
});
// ─── the threaded gate (fix wave, 2026-09-20) ────────────────────────────────
//
// The fix: createTeacherTools/createProberTools used to DROP searchAvailable
// when building the inner JobToolContext, so jobTools' gate always took its
// ?? true default even when the live wiring said unavailable. These pins
// hold the THREADED path (the registry factories, not jobTools directly).
// Exercise live registry factories too: search status must not override
// the existing per-role/user-toggle policy.
import { createProberTools, createTeacherTools } from "../src/agent/tools.ts";
import type { ChatStore } from "../src/core/ports.ts";
@@ -186,19 +270,19 @@ const fakeChatStore = {
const ctxBase = { chatId: "chat-gate-test", chatStore: fakeChatStore };
describe("the threaded searchAvailable gate (registry factories)", () => {
it("createTeacherTools: unavailable drops research on the live wiring path", () => {
describe("keyless role registries", () => {
it("createTeacherTools: unavailable keeps research on the live wiring path", () => {
const tools = createTeacherTools({
...ctxBase,
jobStore: fakeJobStore(),
spawn: () => {},
searchAvailable: () => false,
});
assert.equal("research" in tools, false, "the threaded signal reached jobTools' gate");
assert.ok(tools.research, "no search key is required to dispatch research");
assert.ok(tools.demo && tools.visual && tools.pdf, "the other job tools survive");
});
it("createTeacherTools: available keeps research (the gate reads the signal both ways)", () => {
it("createTeacherTools: available keeps research", () => {
const tools = createTeacherTools({
...ctxBase,
jobStore: fakeJobStore(),
@@ -208,7 +292,7 @@ describe("the threaded searchAvailable gate (registry factories)", () => {
assert.ok(tools.research, "a keyed server exposes research through the teacher");
});
it("createProberTools: research + pdf are phase-omnipresent; the gate drops research only", () => {
it("createProberTools: research + pdf remain phase-omnipresent with or without search keys", () => {
const on = createProberTools({
...ctxBase,
deliver: () => {},
@@ -225,16 +309,25 @@ describe("the threaded searchAvailable gate (registry factories)", () => {
spawn: () => {},
searchAvailable: () => false,
});
assert.equal("research" in off, false, "no engine = no research dispatch in the prober either");
assert.ok(off.research, "keyless research dispatch is available in the prober too");
assert.ok(off.pdf && off.probe_done, "pdf is keyless — it survives; probe_done always-on");
});
it("createProberTools: the user toggle drops research independently of the gate", () => {
it("createProberTools: the user toggle still drops research without search keys", () => {
const tools = createProberTools(
{ ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => true },
{ ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false },
{ research: false },
);
assert.equal("research" in tools, false, "toggle-filtered like the teacher side");
assert.ok(tools.pdf, "pdf unaffected by a research toggle");
});
it("createTeacherTools: the user toggle still drops research without search keys", () => {
const tools = createTeacherTools(
{ ...ctxBase, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false },
{ research: false },
);
assert.equal("research" in tools, false);
assert.ok(tools.pdf && tools.demo && tools.visual);
});
});
+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"'));