Files
oh-my-pi/packages/coding-agent/test/status-line-context-cache.test.ts
T
can1357 d6acef8b76 perf(status-line): replaced index-based token cache with message sidecar cache
- Added Symbol-keyed sidecar on each AgentMessage to memoize estimateTokens, with a cheap content fingerprint to detect in-place mutations.
- Fixed stale cache on same-length replaceMessages, post-hoc error attachment, and branch rebuild edge cases.
- Fixed usage fetch error backoff: stamped fetchedAt on failure so the 5-min TTL also gates retries during outages.
- Extracted computeNonMessageBreakdown as shared helper to prevent drift between status-line and context panel token counts.
2026-05-25 14:06:05 +02:00

203 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression guard for the incremental per-message token cache in
* `StatusLineComponent.getCachedContextBreakdown`.
*
* Before the cache: every call walked `session.messages` and ran
* `estimateTokens` per message (~0.5 ms each native). For a 2,300-message
* session this was a ~1.1 s blocking call. `updateEditorTopBorder()` is
* invoked on every agent event (event-controller.ts:163), so during
* streaming the UI froze for ~1.1 s every 2 s (the prior cache TTL).
*
* After the cache: messages are walked ONCE during warm-up; subsequent
* refreshes append-only update the cache by `messages.length - cached`
* (typically 0–1 new messages). The LAST message is recomputed every call
* because its content may still be growing during streaming. Compaction
* (messages.length shrinks) resets the cache.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings } from "../src/config/settings";
import { StatusLineComponent } from "../src/modes/components/status-line";
import { initTheme } from "../src/modes/theme/theme";
import type { AgentSession } from "../src/session/agent-session";
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
await initTheme();
});
afterAll(() => {
resetSettingsForTest();
});
function makeSession(opts: {
messages: unknown[];
systemPrompt?: string[];
tools?: { name: string; description: string; parameters?: unknown }[];
skills?: { name: string; description: string }[];
contextWindow?: number;
modelId?: string;
}): AgentSession {
return {
messages: opts.messages,
systemPrompt: opts.systemPrompt ?? ["You are a helpful assistant."],
agent: { state: { tools: opts.tools ?? [] } },
skills: opts.skills ?? [],
model: { id: opts.modelId ?? "test-model", contextWindow: opts.contextWindow ?? 200_000 },
} as unknown as AgentSession;
}
function userMessage(text: string): unknown {
return { role: "user", content: text };
}
function assistantMessage(text: string): unknown {
return { role: "assistant", content: [{ type: "text", text }] };
}
describe("StatusLineComponent incremental context breakdown cache", () => {
it("first call computes from scratch, second call returns same value", () => {
const session = makeSession({
messages: Array.from({ length: 50 }, (_, i) => userMessage(`message ${i}`.repeat(10))),
});
const comp = new StatusLineComponent(session);
const first = comp.getCachedContextBreakdown();
const second = comp.getCachedContextBreakdown();
expect(first.usedTokens).toBeGreaterThan(0);
expect(second.usedTokens).toBe(first.usedTokens);
expect(second.contextWindow).toBe(200_000);
});
it("appending a message increases the total by approximately the new message's tokens", () => {
const session = makeSession({
messages: [userMessage("hello world"), userMessage("another message here")],
});
const comp = new StatusLineComponent(session);
const before = comp.getCachedContextBreakdown();
(session.messages as unknown[]).push(assistantMessage("a third message reply with more text content"));
const after = comp.getCachedContextBreakdown();
expect(after.usedTokens).toBeGreaterThan(before.usedTokens);
expect(after.contextWindow).toBe(before.contextWindow);
});
it("compaction (messages.length shrinks) resets the cache and recomputes correctly", () => {
const session = makeSession({
messages: Array.from({ length: 20 }, (_, i) => userMessage(`message ${i}`.repeat(10))),
});
const comp = new StatusLineComponent(session);
const before = comp.getCachedContextBreakdown();
expect(before.usedTokens).toBeGreaterThan(0);
(session.messages as unknown[]).length = 0;
(session.messages as unknown[]).push(userMessage("compacted summary"));
const after = comp.getCachedContextBreakdown();
expect(after.usedTokens).toBeLessThan(before.usedTokens);
expect(after.usedTokens).toBeGreaterThan(0);
});
it("non-message inputs change → recomputes non-message portion", () => {
const session = makeSession({
messages: [userMessage("hi")],
systemPrompt: ["You are an assistant."],
tools: [{ name: "bash", description: "Run shell commands", parameters: {} }],
skills: [{ name: "code", description: "Write code" }],
});
const comp = new StatusLineComponent(session);
const v1 = comp.getCachedContextBreakdown();
const v2 = comp.getCachedContextBreakdown();
expect(v2.usedTokens).toBe(v1.usedTokens);
(session.agent as { state: { tools: unknown[] } }).state.tools.push({
name: "edit",
description: "Edit files",
parameters: {},
});
const v3 = comp.getCachedContextBreakdown();
expect(v3.usedTokens).toBeGreaterThan(v2.usedTokens);
});
it("warm-cache refresh on 200-message session is fast (<100ms for 20 refreshes)", () => {
const session = makeSession({
messages: Array.from({ length: 200 }, (_, i) => userMessage(`msg ${i}`.repeat(20))),
});
const comp = new StatusLineComponent(session);
// Warm-up call (acceptable cost; not measured).
comp.getCachedContextBreakdown();
// 20 warm refreshes; each should only recompute the last message
// (~0.5 ms native) since no other messages changed.
const start = performance.now();
for (let i = 0; i < 20; i++) comp.getCachedContextBreakdown();
const elapsedMs = performance.now() - start;
expect(elapsedMs).toBeLessThan(100);
});
it("zero messages: produces only non-message tokens, no crash", () => {
const session = makeSession({ messages: [] });
const comp = new StatusLineComponent(session);
const result = comp.getCachedContextBreakdown();
expect(result.usedTokens).toBeGreaterThanOrEqual(0);
expect(result.contextWindow).toBe(200_000);
});
it("in-place mutation of a non-last message recomputes its tokens", () => {
const original = userMessage("short") as { content: string };
const tail = userMessage("tail");
const session = makeSession({ messages: [original, tail] });
const comp = new StatusLineComponent(session);
const before = comp.getCachedContextBreakdown();
// Mutate messages[0] in place — same object, larger content.
original.content = "a much longer body that should tokenize to more".repeat(20);
const after = comp.getCachedContextBreakdown();
expect(after.usedTokens).toBeGreaterThan(before.usedTokens);
});
it("replaceMessages with same length but different shape recomputes tokens", () => {
const session = makeSession({
messages: [userMessage("short a"), userMessage("short b")],
});
const comp = new StatusLineComponent(session);
const before = comp.getCachedContextBreakdown();
// Same-length replace: distinct message objects with larger payloads.
(session as { messages: unknown[] }).messages = [
userMessage("a much longer payload".repeat(20)),
userMessage("another longer payload".repeat(20)),
];
const after = comp.getCachedContextBreakdown();
expect(after.usedTokens).toBeGreaterThan(before.usedTokens);
});
it("usage fetch error backs off — a failed fetch does not retrigger within the TTL window", async () => {
const session = makeSession({ messages: [userMessage("hi")] });
let calls = 0;
(session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports = () => {
calls++;
return Promise.reject(new Error("network"));
};
const comp = new StatusLineComponent(session);
// First refresh → kicks off fetch #1.
comp.refreshUsageInBackground();
// Let the rejected fetch settle so the .catch backoff stamp lands.
await Bun.sleep(0);
expect(calls).toBe(1);
// Subsequent refreshes within the TTL window must not refetch.
comp.refreshUsageInBackground();
comp.refreshUsageInBackground();
await Bun.sleep(0);
expect(calls).toBe(1);
});
});