Compare commits

..
Author SHA1 Message Date
StanPonomarev ba9fddad9d Merge pull request 'fix: remove cost estim' (#11) from fix/remove-cost-estim into main
Reviewed-on: #11
2026-09-16 20:34:57 +02:00
StanPonomarev b4ab44eb3c fix: remove cost estim
CI / verify (pull_request) Successful in 9s
2026-09-16 20:30:22 +02:00
StanPonomarev 693589c21c Merge pull request 'fix: update deps for opencode 2.0.4' (#10) from fix/opencode204 into main
Release / release (push) Successful in 27s
Reviewed-on: #10
2026-09-16 09:51:07 +02:00
6 changed files with 73 additions and 171 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ export default Plugin.define({
await quotaRegistration?.events.emit("updated", {}); await quotaRegistration?.events.emit("updated", {});
}); });
quotaRegistration = await context.rpc.register(QuotaRpc, { quotaRegistration = await context.rpc.register(QuotaRpc, {
status: async ({ sessionID }) => (await quota.status(sessionID)) ?? null, status: async () => quota.status() ?? null,
}); });
await quota.init(); await quota.init();
// Subscribe before the first read: configuration can publish its models while // Subscribe before the first read: configuration can publish its models while
+1 -2
View File
@@ -8,14 +8,13 @@ const window = z.object({
used: z.number(), used: z.number(),
resetsAt: z.number(), resetsAt: z.number(),
remaining: z.number(), remaining: z.number(),
sessionConsumed: z.number(),
}); });
export const QuotaRpc = Rpc.define({ export const QuotaRpc = Rpc.define({
id: "stanp-opencode.quota", id: "stanp-opencode.quota",
methods: { methods: {
status: { status: {
input: z.object({ sessionID: z.string() }), input: z.object({}),
output: z output: z
.object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) }) .object({ plan: z.string(), fetchedAt: z.number(), windows: z.array(window) })
.nullable(), .nullable(),
+30 -108
View File
@@ -54,7 +54,7 @@ export interface QuotaSnapshot {
export interface QuotaStatus { export interface QuotaStatus {
plan: string; plan: string;
fetchedAt: number; fetchedAt: number;
windows: Array<QuotaWindow & { remaining: number; sessionConsumed: number }>; windows: Array<QuotaWindow & { remaining: number }>;
} }
const storedState = z.object({ const storedState = z.object({
@@ -74,14 +74,8 @@ const storedState = z.object({
), ),
}) })
.optional(), .optional(),
sessions: z.record(
z.string(),
z.record(
z.string(),
z.object({ resetsAt: z.number(), lastUsed: z.number(), consumed: z.number() }),
),
),
}); });
const legacyStoredState = storedState.extend({ sessions: z.unknown() });
type StoredState = z.infer<typeof storedState>; type StoredState = z.infer<typeof storedState>;
function withoutExcludedState(state: StoredState): StoredState { function withoutExcludedState(state: StoredState): StoredState {
@@ -89,20 +83,10 @@ function withoutExcludedState(state: StoredState): StoredState {
return { return {
...state, ...state,
latest: state.latest && windows?.length ? { ...state.latest, windows } : undefined, latest: state.latest && windows?.length ? { ...state.latest, windows } : undefined,
sessions: Object.fromEntries(
Object.entries(state.sessions).map(([session, tracked]) => [
session,
Object.fromEntries(
Object.entries(tracked).filter(
([id]) => !isExcludedLimit(id.split(":", 1)[0], undefined),
),
),
]),
),
}; };
} }
type QuotaContext = Pick<Plugin.Context, "integration" | "session" | "storage">; type QuotaContext = Pick<Plugin.Context, "integration" | "storage">;
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>; type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
const quotaWindow = z.object({ const quotaWindow = z.object({
@@ -239,7 +223,8 @@ export class QuotaTracker {
private timer: ReturnType<typeof setInterval> | undefined; private timer: ReturnType<typeof setInterval> | undefined;
private request: Promise<QuotaSnapshot | undefined> | undefined; private request: Promise<QuotaSnapshot | undefined> | undefined;
private readonly requests = new Set<Promise<QuotaSnapshot | undefined>>(); private readonly requests = new Set<Promise<QuotaSnapshot | undefined>>();
private readonly active = new Map<string, Set<string>>(); private readonly migrations = new Set<Promise<void>>();
private readonly active = new Set<string>();
private controller: AbortController | undefined; private controller: AbortController | undefined;
private generation = 0; private generation = 0;
private disposed = false; private disposed = false;
@@ -276,14 +261,22 @@ export class QuotaTracker {
: (this.state?.account ?? (await fingerprint(`credential:${credential.refresh}`))); : (this.state?.account ?? (await fingerprint(`credential:${credential.refresh}`)));
if (generation !== this.generation) return; if (generation !== this.generation) return;
if (this.state?.account !== account) { if (this.state?.account !== account) {
const parsed = storedState.safeParse(await this.context.storage.get(STORAGE_KEY)); const value = await this.context.storage.get(STORAGE_KEY);
const parsed = storedState.safeParse(value);
if (generation !== this.generation) return; if (generation !== this.generation) return;
const stored = parsed.success ? parsed.data : undefined; const stored = parsed.success ? parsed.data : undefined;
if (generation !== this.generation) return; if (generation !== this.generation) return;
this.state = this.state = stored?.account === account ? withoutExcludedState(stored) : { account };
stored?.account === account const legacy = legacyStoredState.safeParse(value);
? withoutExcludedState(stored) if (stored?.account === account && legacy.success) {
: { account, sessions: {} }; const migration = this.persist();
this.migrations.add(migration);
try {
await migration;
} finally {
this.migrations.delete(migration);
}
}
} }
if (generation !== this.generation) return; if (generation !== this.generation) return;
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs); if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
@@ -345,7 +338,7 @@ export class QuotaTracker {
if (parsed.success && parsed.data.account_id) { if (parsed.success && parsed.data.account_id) {
const account = await fingerprint(`account:${parsed.data.account_id}`); const account = await fingerprint(`account:${parsed.data.account_id}`);
if (generation !== this.generation || !this.state) return; if (generation !== this.generation || !this.state) return;
if (account !== this.state.account) this.state = { account, sessions: {} }; if (account !== this.state.account) this.state = { account };
} }
const snapshot = normalizeQuota(payload); const snapshot = normalizeQuota(payload);
if (!snapshot) return; if (!snapshot) return;
@@ -360,102 +353,30 @@ export class QuotaTracker {
private apply(snapshot: QuotaSnapshot) { private apply(snapshot: QuotaSnapshot) {
if (!this.state) return; if (!this.state) return;
for (const root of this.active.keys()) {
const tracked = (this.state.sessions[root] ??= {});
for (const window of snapshot.windows) {
const previous = tracked[window.id];
if (!previous) {
tracked[window.id] = {
resetsAt: window.resetsAt,
lastUsed: window.used,
consumed: 0,
};
continue;
}
if (previous.resetsAt !== window.resetsAt) {
previous.resetsAt = window.resetsAt;
previous.lastUsed = window.used;
previous.consumed += window.used;
} else if (window.used >= previous.lastUsed) {
previous.consumed += window.used - previous.lastUsed;
previous.lastUsed = window.used;
}
}
}
this.state.latest = snapshot; this.state.latest = snapshot;
} }
private async root(sessionID: string) {
let current = sessionID;
const seen = new Set<string>();
while (!seen.has(current)) {
seen.add(current);
const session = await this.context.session.get({ sessionID: current });
if (!session.parentID) return current;
current = session.parentID;
}
return sessionID;
}
async start(sessionID: string) { async start(sessionID: string) {
if (this.active.has(sessionID)) return;
const generation = this.generation; const generation = this.generation;
try { const snapshot = await this.refresh();
const root = await this.root(sessionID); if (!snapshot || generation !== this.generation) return;
if (generation !== this.generation) return; this.active.add(sessionID);
const family = this.active.get(root);
if (family?.has(sessionID)) return;
const snapshot = await this.refresh();
if (!snapshot || !this.state || generation !== this.generation) return;
if (!family) {
const previous = this.state.sessions[root] ?? {};
this.state.sessions[root] = Object.fromEntries(
snapshot.windows.map((window) => [
window.id,
{
resetsAt: window.resetsAt,
lastUsed: window.used,
consumed: previous[window.id]?.consumed ?? 0,
},
]),
);
}
await this.persist();
if (generation !== this.generation) return;
await this.changed();
if (generation !== this.generation) return;
const active = family ?? new Set<string>();
active.add(sessionID);
this.active.set(root, active);
} catch {
return;
}
} }
async complete(sessionID: string) { async complete(sessionID: string) {
try { if (!this.active.delete(sessionID)) return;
const root = await this.root(sessionID); await this.refresh();
const family = this.active.get(root);
if (!family?.has(sessionID)) return;
await this.refresh();
family.delete(sessionID);
if (family.size === 0) this.active.delete(root);
await this.persist();
await this.changed();
} catch {
return;
}
} }
async status(sessionID: string): Promise<QuotaStatus | undefined> { status(): QuotaStatus | undefined {
if (!this.state?.latest) return; if (!this.state?.latest) return;
const tracked = this.state.sessions[await this.root(sessionID)];
return { return {
plan: this.state.latest.plan, plan: this.state.latest.plan,
fetchedAt: this.state.latest.fetchedAt, fetchedAt: this.state.latest.fetchedAt,
windows: this.state.latest.windows.map((window) => ({ windows: this.state.latest.windows.map((window) => ({
...window, ...window,
remaining: Math.max(0, 100 - window.used), remaining: Math.max(0, 100 - window.used),
sessionConsumed: tracked?.[window.id]?.consumed ?? 0,
})), })),
}; };
} }
@@ -465,10 +386,9 @@ export class QuotaTracker {
const value: Schema.Json = this.state.latest const value: Schema.Json = this.state.latest
? { ? {
account: this.state.account, account: this.state.account,
sessions: this.state.sessions,
latest: this.state.latest, latest: this.state.latest,
} }
: { account: this.state.account, sessions: this.state.sessions }; : { account: this.state.account };
await this.context.storage.set(STORAGE_KEY, value); await this.context.storage.set(STORAGE_KEY, value);
} }
@@ -478,6 +398,8 @@ export class QuotaTracker {
this.controller?.abort(); this.controller?.abort();
this.controller = undefined; this.controller = undefined;
this.request = undefined; this.request = undefined;
this.active.clear();
await Promise.all(this.migrations);
if (this.state) await this.clear(); if (this.state) await this.clear();
} }
await this.refresh(); await this.refresh();
@@ -489,6 +411,6 @@ export class QuotaTracker {
this.controller?.abort(); this.controller?.abort();
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = undefined; this.timer = undefined;
await Promise.all(this.requests); await Promise.all([...this.requests, ...this.migrations]);
} }
} }
+3 -6
View File
@@ -33,14 +33,11 @@ function resetTiming(resetsAt: number, now: number) {
return `${minutes}m`; return `${minutes}m`;
} }
export function formatQuotaWindow( export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) {
window: QuotaWindow & { remaining: number; sessionConsumed: number },
now = Date.now(),
) {
const label = window.label const label = window.label
? `${window.label} ${duration(window.seconds)}` ? `${window.label} ${duration(window.seconds)}`
: duration(window.seconds); : duration(window.seconds);
return `${label}: ${percent(window.remaining)}% · ~${percent(window.sessionConsumed)}pp · ${resetTiming(window.resetsAt, now)}`; return `${label}: ${percent(window.remaining)}% · ${resetTiming(window.resetsAt, now)}`;
} }
export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) { export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) {
@@ -66,7 +63,7 @@ export default Plugin.define({
const Slot = (props: { sessionID: string }) => { const Slot = (props: { sessionID: string }) => {
const [status] = createResource( const [status] = createResource(
() => `${props.sessionID}:${revision()}`, () => `${props.sessionID}:${revision()}`,
() => loadQuotaStatus(() => quota.status({ sessionID: props.sessionID })), () => loadQuotaStatus(() => quota.status({})),
); );
return ( return (
<Show when={status()}> <Show when={status()}>
+31 -47
View File
@@ -173,10 +173,6 @@ function fixture(
storageReads: Promise<Schema.Json | undefined>[] = [], storageReads: Promise<Schema.Json | undefined>[] = [],
) { ) {
const stored = new Map<string, Schema.Json>(); const stored = new Map<string, Schema.Json>();
const sessions = new Map([
["root", {}],
["child", { parentID: "root" }],
]);
let calls = 0; let calls = 0;
let storageCalls = 0; let storageCalls = 0;
let changes = 0; let changes = 0;
@@ -198,12 +194,6 @@ function fixture(
}, },
}, },
} as never, } as never,
session: {
async get({ sessionID }: { sessionID: string }) {
// SAFETY: QuotaTracker reads only parentID from this focused fixture.
return sessions.get(sessionID) as never;
},
} as never,
storage: { storage: {
async get(key: string) { async get(key: string) {
storageCalls++; storageCalls++;
@@ -242,6 +232,7 @@ function fixture(
storageCalls: () => storageCalls, storageCalls: () => storageCalls,
changes: () => changes, changes: () => changes,
headers, headers,
stored,
switch(credential: typeof current) { switch(credential: typeof current) {
current = credential; current = credential;
}, },
@@ -256,7 +247,7 @@ async function accountFingerprint(id: string) {
return Buffer.from(bytes).toString("hex"); return Buffer.from(bytes).toString("hex");
} }
test("removes cached Spark and representative reserve windows and consumption while offline", async () => { test("reads old cached snapshots without session estimates and removes excluded windows", async () => {
const cached = { const cached = {
account: await accountFingerprint("account-one"), account: await accountFingerprint("account-one"),
latest: { latest: {
@@ -331,22 +322,23 @@ test("removes cached Spark and representative reserve windows and consumption wh
); );
await result.tracker.init(); await result.tracker.init();
const status = await result.tracker.status("root"); const status = result.tracker.status();
expect(status?.windows.map((window) => window.id)).toEqual([ expect(status?.windows.map((window) => window.id)).toEqual([
"codex:primary", "codex:primary",
"codex_luna:primary", "codex_luna:primary",
]); ]);
expect(status?.windows[0]?.sessionConsumed).toBe(2); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
expect(status?.windows[1]?.sessionConsumed).toBe(3); expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
expect(result.calls()).toBe(0);
await result.tracker.dispose(); await result.tracker.dispose();
}); });
test("activates only for OAuth and establishes an awaited root baseline shared by children", async () => { test("activates only for OAuth and refreshes quota at request start", async () => {
const key = fixture({ type: "key", key: "secret" }, [plus]); const key = fixture({ type: "key", key: "secret" }, [plus]);
await key.tracker.init(); await key.tracker.init();
await key.tracker.start("root"); await key.tracker.start("root");
expect(key.calls()).toBe(0); expect(key.calls()).toBe(0);
expect(await key.tracker.status("root")).toBeUndefined(); expect(key.tracker.status()).toBeUndefined();
await key.tracker.dispose(); await key.tracker.dispose();
const oauth = fixture( const oauth = fixture(
@@ -372,13 +364,14 @@ test("activates only for OAuth and establishes an awaited root baseline shared b
expect(oauth.calls()).toBe(1); expect(oauth.calls()).toBe(1);
expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one"); expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one");
await oauth.tracker.refresh(); await oauth.tracker.refresh();
const child = await oauth.tracker.status("child"); const status = oauth.tracker.status();
expect(child?.windows[0]?.sessionConsumed).toBe(5); expect(status?.windows[0]?.remaining).toBe(82.5);
expect(child?.windows[0]?.remaining).toBe(82.5); expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
expect(oauth.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
await oauth.tracker.dispose(); await oauth.tracker.dispose();
}); });
test("polls, carries consumption across quota resets, tolerates errors, and clears on logout", async () => { test("polls, tolerates errors, and clears on logout", async () => {
const reset = { const reset = {
...plus, ...plus,
rate_limit: { rate_limit: {
@@ -403,12 +396,12 @@ test("polls, carries consumption across quota resets, tolerates errors, and clea
await result.tracker.init(); await result.tracker.init();
await result.tracker.start("root"); await result.tracker.start("root");
await Bun.sleep(35); await Bun.sleep(35);
const status = await result.tracker.status("root"); const status = result.tracker.status();
expect(result.calls()).toBeGreaterThanOrEqual(3); expect(result.calls()).toBeGreaterThanOrEqual(3);
expect(status?.windows[0]?.sessionConsumed).toBe(2); expect(status?.windows[0]?.remaining).toBe(98);
result.logout(); result.logout();
await result.tracker.authChanged(true); await result.tracker.authChanged(true);
expect(await result.tracker.status("root")).toBeUndefined(); expect(result.tracker.status()).toBeUndefined();
const calls = result.calls(); const calls = result.calls();
await result.tracker.dispose(); await result.tracker.dispose();
await Bun.sleep(20); await Bun.sleep(20);
@@ -424,12 +417,11 @@ test("sidebar text places reset timing beside latest remaining quota", () => {
label: null, label: null,
used: 25, used: 25,
remaining: 75, remaining: 75,
sessionConsumed: 1.5,
resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000, resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000,
}, },
100_000, 100_000,
), ),
).toBe("7d: 75% · ~1.5pp · 2d 5h"); ).toBe("7d: 75% · 2d 5h");
}); });
test("sidebar reset timing uses compact future units and handles boundaries", () => { test("sidebar reset timing uses compact future units and handles boundaries", () => {
@@ -442,17 +434,16 @@ test("sidebar reset timing uses compact future units and handles boundaries", ()
label: null, label: null,
used: 25, used: 25,
remaining: 75, remaining: 75,
sessionConsumed: 1.5,
resetsAt: now + resetIn, resetsAt: now + resetIn,
}, },
now, now,
); );
expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% · ~1.5pp · 2h 15m"); expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% · 2h 15m");
expect(format(3_600_000)).toBe("5h: 75% · ~1.5pp · 1h"); expect(format(3_600_000)).toBe("5h: 75% · 1h");
expect(format(15 * 60_000)).toBe("5h: 75% · ~1.5pp · 15m"); expect(format(15 * 60_000)).toBe("5h: 75% · 15m");
expect(format(60_000)).toBe("5h: 75% · ~1.5pp · 1m"); expect(format(60_000)).toBe("5h: 75% · 1m");
expect(format(59_999)).toBe("5h: 75% · ~1.5pp · <1m"); expect(format(59_999)).toBe("5h: 75% · <1m");
}); });
test("sidebar reset timing preserves labels and distinguishes stale and unavailable timestamps", () => { test("sidebar reset timing preserves labels and distinguishes stale and unavailable timestamps", () => {
@@ -462,17 +453,16 @@ test("sidebar reset timing preserves labels and distinguishes stale and unavaila
label: "GPT-5.6 Luna", label: "GPT-5.6 Luna",
used: 99.95, used: 99.95,
remaining: 0.05, remaining: 0.05,
sessionConsumed: 1.5,
}; };
expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe( expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe(
"GPT-5.6 Luna 5h: <0.1% · ~1.5pp · due", "GPT-5.6 Luna 5h: <0.1% · due",
); );
expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe( expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe(
"GPT-5.6 Luna 5h: <0.1% · ~1.5pp · unavailable", "GPT-5.6 Luna 5h: <0.1% · unavailable",
); );
}); });
test("inactive roots freeze, resume from a cached baseline on failure, and deactivate by family", async () => { test("request completion refreshes once across terminal and idle events", async () => {
const used = (percent: number) => ({ const used = (percent: number) => ({
...plus, ...plus,
rate_limit: { rate_limit: {
@@ -492,16 +482,10 @@ test("inactive roots freeze, resume from a cached baseline on failure, and deact
await result.tracker.init(); await result.tracker.init();
await result.tracker.start("root"); await result.tracker.start("root");
await result.tracker.complete("root"); await result.tracker.complete("root");
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(1); expect(result.tracker.status()?.windows[0]?.remaining).toBe(89);
await result.tracker.refresh();
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(1);
await result.tracker.start("root");
await result.tracker.start("child");
await result.tracker.complete("root"); await result.tracker.complete("root");
await result.tracker.refresh(); expect(result.calls()).toBe(2);
await result.tracker.complete("child"); expect(result.tracker.status()?.windows[0]?.remaining).toBe(89);
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(6);
await result.tracker.dispose(); await result.tracker.dispose();
}); });
@@ -542,10 +526,10 @@ test("an auth switch detaches an in-flight old-account response and immediately
expect(result.calls()).toBe(2); expect(result.calls()).toBe(2);
pending.resolve(plus); pending.resolve(plus);
await old; await old;
expect((await result.tracker.status("root"))?.windows[0]?.used).toBe(70); expect(result.tracker.status()?.windows[0]?.used).toBe(70);
expect(result.headers[1]?.get("chatgpt-account-id")).toBe("account-two"); expect(result.headers[1]?.get("chatgpt-account-id")).toBe("account-two");
await result.tracker.refresh(); await result.tracker.refresh();
expect((await result.tracker.status("root"))?.windows[0]?.sessionConsumed).toBe(0); expect(result.tracker.status()?.windows[0]).not.toHaveProperty("sessionConsumed");
await result.tracker.dispose(); await result.tracker.dispose();
}); });
@@ -577,7 +561,7 @@ test("an auth switch during an old account storage read cannot restore stale sta
expect(result.calls()).toBe(1); expect(result.calls()).toBe(1);
pending.resolve(undefined); pending.resolve(undefined);
await old; await old;
expect((await result.tracker.status("root"))?.windows[0]?.used).toBe(64); expect(result.tracker.status()?.windows[0]?.used).toBe(64);
expect(result.headers[0]?.get("chatgpt-account-id")).toBe("account-two"); expect(result.headers[0]?.get("chatgpt-account-id")).toBe("account-two");
await result.tracker.dispose(); await result.tracker.dispose();
}); });
+7 -7
View File
@@ -18,7 +18,6 @@ const status = (plan: string, remaining: number): QuotaStatus => ({
used: 100 - remaining, used: 100 - remaining,
resetsAt: 2, resetsAt: 2,
remaining, remaining,
sessionConsumed: 1.5,
}, },
], ],
}); });
@@ -32,13 +31,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
const { default: plugin } = tui; const { default: plugin } = tui;
const pending = Promise.withResolvers<QuotaStatus | null>(); const pending = Promise.withResolvers<QuotaStatus | null>();
const responses: Array<() => Promise<QuotaStatus | null>> = [() => pending.promise]; const responses: Array<() => Promise<QuotaStatus | null>> = [() => pending.promise];
const sessions: string[] = []; let statusCalls = 0;
let slotRender: SlotRender | undefined; let slotRender: SlotRender | undefined;
let notifyUpdated: (() => void) | undefined; let notifyUpdated: (() => void) | undefined;
const quota = { const quota = {
status(input: { sessionID: string }) { status() {
sessions.push(input.sessionID); statusCalls++;
const response = responses.shift(); const response = responses.shift();
if (!response) throw new Error("Missing test response"); if (!response) throw new Error("Missing test response");
return response(); return response();
@@ -84,7 +83,8 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
pending.resolve(status("plus", 75)); pending.resolve(status("plus", 75));
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)")); await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)"));
expect(setup.captureCharFrame()).toContain("5h: 75% · ~1.5pp · due"); expect(setup.captureCharFrame()).toContain("5h: 75% · due");
expect(setup.captureCharFrame()).not.toContain("~");
responses.push(async () => null); responses.push(async () => null);
notifyUpdated(); notifyUpdated();
@@ -94,13 +94,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
throw new Error("disconnected"); throw new Error("disconnected");
}); });
notifyUpdated(); notifyUpdated();
await setup.waitFor(() => sessions.length === 3); await setup.waitFor(() => statusCalls === 3);
expect(setup.captureCharFrame()).not.toContain("ChatGPT quota"); expect(setup.captureCharFrame()).not.toContain("ChatGPT quota");
responses.push(async () => status("pro", 42)); responses.push(async () => status("pro", 42));
setSessionID("session-two"); setSessionID("session-two");
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)")); await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)"));
expect(sessions).toEqual(["session-one", "session-one", "session-one", "session-two"]); expect(statusCalls).toBe(4);
} finally { } finally {
setup.renderer.destroy(); setup.renderer.destroy();
if (cleanup) await cleanup(); if (cleanup) await cleanup();