3df97d5097
Addresses the third review round (Codex on 31cd0e4f5 + roboomp): - Usage-path refresh row matching no longer compares the shared REMOTE_REFRESH_SENTINEL as a refresh-token identity — with broker-backed rows every credential carries the same sentinel, so the comparison matched the FIRST OAuth row and could persist another org's rotated token into the wrong row. The sentinel is now non-identifying; matching falls through to access token, then account/email/project/org equality. Regression: two same-email sentinel rows, expired Max row — the refresh persists into the Max row only. - roboomp's status-line finding (usage context key without orgId) was already fixed in 31cd0e4f5 — its review raced the push. Added the requested regression: rotating orgs under one email/account invalidates the cached usage immediately; same-org calls stay cached.
204 lines
5.3 KiB
TypeScript
204 lines
5.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { stripVTControlCharacters } from "node:util";
|
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
function makeSession(fetchUsageReports: (signal?: AbortSignal) => Promise<unknown>): AgentSession {
|
|
const messages: unknown[] = [];
|
|
return {
|
|
fetchUsageReports,
|
|
messages,
|
|
state: { messages, model: { contextWindow: 200_000 } },
|
|
model: { contextWindow: 200_000 },
|
|
isStreaming: false,
|
|
sessionManager: {
|
|
getUsageStatistics: () => ({
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
orchestrationInput: 0,
|
|
orchestrationOutput: 0,
|
|
orchestrationCacheRead: 0,
|
|
premiumRequests: 0,
|
|
cost: 0,
|
|
}),
|
|
getSessionName: () => "test",
|
|
},
|
|
getAsyncJobSnapshot: () => ({ running: [] }),
|
|
getContextUsage: () => undefined,
|
|
contextUsageRevision: 0,
|
|
} as unknown as AgentSession;
|
|
}
|
|
|
|
function usageReport(percent: number): unknown[] {
|
|
return [
|
|
{
|
|
provider: "anthropic",
|
|
fetchedAt: Date.now(),
|
|
limits: [
|
|
{
|
|
id: "anthropic:5h",
|
|
label: "Claude 5 Hour",
|
|
scope: { provider: "anthropic", windowId: "5h" },
|
|
window: { id: "5h", label: "5h", resetsAt: Date.now() + 60_000 },
|
|
amount: { unit: "percent", usedFraction: percent / 100 },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
function plain(text: string): string {
|
|
return stripVTControlCharacters(text);
|
|
}
|
|
|
|
describe("StatusLineComponent usage refresh", () => {
|
|
beforeEach(async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({ inMemory: true });
|
|
await initTheme();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
resetSettingsForTest();
|
|
});
|
|
|
|
it("does not invoke usage fetching synchronously on the render path", async () => {
|
|
let calls = 0;
|
|
const component = new StatusLineComponent(
|
|
makeSession(async () => {
|
|
calls++;
|
|
return [];
|
|
}),
|
|
);
|
|
|
|
component.refreshUsageInBackground();
|
|
expect(calls).toBe(0);
|
|
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("passes a startup timeout signal to the background usage fetch", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
const component = new StatusLineComponent(
|
|
makeSession(async nextSignal => {
|
|
signal = nextSignal;
|
|
return [];
|
|
}),
|
|
);
|
|
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
|
|
expect(signal).toBeInstanceOf(AbortSignal);
|
|
});
|
|
|
|
it("backs off after the startup timeout when usage fetching hangs", async () => {
|
|
let calls = 0;
|
|
const component = new StatusLineComponent(
|
|
makeSession(() => {
|
|
calls++;
|
|
return Promise.withResolvers<unknown>().promise;
|
|
}),
|
|
);
|
|
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
expect(calls).toBe(1);
|
|
|
|
component.refreshUsageInBackground();
|
|
expect(calls).toBe(1);
|
|
|
|
vi.advanceTimersByTime(2_000);
|
|
await flushMicrotasks();
|
|
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("applies late usage reports that resolve after the startup timeout", async () => {
|
|
const late = Promise.withResolvers<unknown>();
|
|
const component = new StatusLineComponent(makeSession(() => late.promise));
|
|
component.updateSettings({
|
|
preset: "custom",
|
|
leftSegments: ["usage"],
|
|
rightSegments: [],
|
|
separator: "powerline-thin",
|
|
});
|
|
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
vi.advanceTimersByTime(2_000);
|
|
await flushMicrotasks();
|
|
|
|
expect(plain(component.getTopBorder(80).content)).not.toContain("5h");
|
|
|
|
late.resolve(usageReport(42));
|
|
await flushMicrotasks();
|
|
|
|
expect(plain(component.getTopBorder(80).content)).toContain("5h 42%");
|
|
});
|
|
|
|
it("re-fetches usage immediately when the session rotates to another org under the same email", async () => {
|
|
let calls = 0;
|
|
let orgId = "org-team";
|
|
const base = makeSession(async () => {
|
|
calls++;
|
|
return usageReport(10);
|
|
}) as unknown as Record<string, unknown>;
|
|
// Same provider + email + account throughout — only the org rotates.
|
|
base.state = {
|
|
messages: [],
|
|
model: { contextWindow: 200_000, provider: "anthropic" },
|
|
};
|
|
base.modelRegistry = {
|
|
authStorage: {
|
|
getOAuthAccountIdentity: () => ({
|
|
email: "shared@example.com",
|
|
accountId: "account-shared",
|
|
orgId,
|
|
}),
|
|
},
|
|
};
|
|
const component = new StatusLineComponent(base as unknown as AgentSession);
|
|
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
expect(calls).toBe(1);
|
|
|
|
// Same org within the cache TTL: served from cache, no refetch.
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
expect(calls).toBe(1);
|
|
|
|
// Org rotation under the same email/account must invalidate the cache.
|
|
orgId = "org-max";
|
|
component.refreshUsageInBackground();
|
|
vi.advanceTimersByTime(0);
|
|
await flushMicrotasks();
|
|
expect(calls).toBe(2);
|
|
});
|
|
});
|