Files
oh-my-pi/packages/coding-agent/test/status-line-usage-refresh.test.ts
T
chan1103 3df97d5097 fix(ai): broker refresh sentinel is not row identity; org-rotation cache regression tests
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.
2026-07-11 22:49:12 +09:00

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);
});
});