import { describe, expect, it } from "vitest";
import type { Card } from "../src/cli/ui/state/cards.js";
import type { AgentEvent } from "../src/cli/ui/state/events.js";
import { reduce } from "../src/cli/ui/state/reducer.js";
import { type AgentState, type SessionInfo, initialState } from "../src/cli/ui/state/state.js";
const session: SessionInfo = {
id: "mem-budget",
branch: "main",
workspace: "/tmp/repo",
model: "deepseek-chat",
};
const SIZES = {
userPrompt: 200,
reasoningChars: 50_000,
streamingChars: 15_000,
toolOutputChars: 25_000,
toolsPerTurn: 2,
};
interface SizeReport {
cardCount: number;
totalBytes: number;
byKind: Map<string, { count: number; bytes: number }>;
byField: Map<string, number>;
}
function bytesUtf8(s: string): number {
return Buffer.byteLength(s, "utf8");
}
function measureCards(cards: ReadonlyArray<Card>): SizeReport {
const byKind = new Map<string, { count: number; bytes: number }>();
const byField = new Map<string, number>();
let total = 0;
const bump = (kind: string, field: string, b: number) => {
const k = byKind.get(kind) ?? { count: 0, bytes: 0 };
k.bytes += b;
byKind.set(kind, k);
byField.set(`${kind}.${field}`, (byField.get(`${kind}.${field}`) ?? 0) + b);
total += b;
};
for (const c of cards) {
const k = byKind.get(c.kind) ?? { count: 0, bytes: 0 };
k.count++;
byKind.set(c.kind, k);
bump(c.kind, "_meta", 64);
bump(c.kind, "id", bytesUtf8(c.id));
switch (c.kind) {
case "user":
bump("user", "text", bytesUtf8(c.text));
break;
case "reasoning":
bump("reasoning", "text", bytesUtf8(c.text));
break;
case "streaming":
bump("streaming", "text", bytesUtf8(c.text));
break;
case "tool":
bump("tool", "output", bytesUtf8(c.output));
bump("tool", "name", bytesUtf8(c.name));
bump("tool", "args", bytesUtf8(JSON.stringify(c.args ?? null)));
break;
case "diff":
for (const h of c.hunks) {
bump("diff", "header", bytesUtf8(h.header));
for (const l of h.lines) bump("diff", "lines", bytesUtf8(l.text));
}
break;
default:
break;
}
}
return { cardCount: cards.length, totalBytes: total, byKind, byField };
}
function buildTurnEvents(turnIdx: number): AgentEvent[] {
const evs: AgentEvent[] = [];
const tag = `t${turnIdx}`;
evs.push({ type: "user.submit", text: "u".repeat(SIZES.userPrompt) });
evs.push({ type: "reasoning.start", id: `${tag}-r` });
evs.push({ type: "reasoning.chunk", id: `${tag}-r`, text: "r".repeat(SIZES.reasoningChars) });
evs.push({ type: "reasoning.end", id: `${tag}-r`, paragraphs: 1, tokens: 1000 });
evs.push({ type: "streaming.start", id: `${tag}-s` });
evs.push({ type: "streaming.chunk", id: `${tag}-s`, text: "s".repeat(SIZES.streamingChars) });
evs.push({ type: "streaming.end", id: `${tag}-s` });
for (let i = 0; i < SIZES.toolsPerTurn; i++) {
const tid = `${tag}-tool${i}`;
evs.push({ type: "tool.start", id: tid, name: "read_file", args: { path: "a/b/c.ts" } });
evs.push({
type: "tool.end",
id: tid,
output: "o".repeat(SIZES.toolOutputChars),
elapsedMs: 50,
});
}
return evs;
}
function runSession(turns: number): AgentState {
let state = initialState(session);
for (let t = 0; t < turns; t++) {
for (const ev of buildTurnEvents(t)) state = reduce(state, ev);
}
return state;
}
function rawInputBytes(turns: number): number {
const perTurn =
SIZES.userPrompt +
SIZES.reasoningChars +
SIZES.streamingChars +
SIZES.toolOutputChars * SIZES.toolsPerTurn;
return perTurn * turns;
}
function formatReport(label: string, r: SizeReport): string {
const mb = (b: number) => (b / 1024 / 1024).toFixed(2);
const lines: string[] = [];
lines.push(`\n--- ${label} ---`);
lines.push(`cards retained: ${r.cardCount}, total bytes: ${mb(r.totalBytes)} MB`);
const kinds = [...r.byKind.entries()].sort((a, b) => b[1].bytes - a[1].bytes);
for (const [kind, info] of kinds) {
lines.push(
` ${kind.padEnd(10)} count=${String(info.count).padStart(5)} ${mb(info.bytes).padStart(7)} MB`,
);
}
lines.push(" top field consumers:");
const fields = [...r.byField.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
for (const [name, b] of fields) {
lines.push(` ${name.padEnd(22)} ${mb(b).padStart(7)} MB`);
}
return lines.join("\n");
}
describe("cards memory budget end-to-end (issue #1031)", () => {
it("100-turn session stays well under window — every card kept full", () => {
const turns = 100;
const r = measureCards(runSession(turns).cards);
console.log(formatReport(`${turns}-turn session (below elision window)`, r));
expect(r.cardCount).toBeLessThan(600);
expect(r.totalBytes).toBeLessThan(rawInputBytes(turns) + 1024 * 1024);
});
it("1000-turn session: elision keeps retained bytes under 10% of raw input", () => {
const turns = 1000;
const r = measureCards(runSession(turns).cards);
const raw = rawInputBytes(turns);
console.log(formatReport(`${turns}-turn session (post-elision)`, r));
console.log(
`\nelided=${(r.totalBytes / 1024 / 1024).toFixed(2)} MB, raw-input=${(raw / 1024 / 1024).toFixed(2)} MB, saved=${(((raw - r.totalBytes) / raw) * 100).toFixed(1)}%\n`,
);
expect(r.totalBytes).toBeLessThan(raw * 0.1);
});
it("no single card kind retains more than the recent window's worth of full content", () => {
const turns = 1000;
const r = measureCards(runSession(turns).cards);
const ceiling = (kind: string, perCardBytes: number, retainedFullCards = 200) =>
perCardBytes * retainedFullCards + r.byKind.get(kind)!.count * 256;
expect(r.byKind.get("tool")!.bytes).toBeLessThan(ceiling("tool", SIZES.toolOutputChars));
expect(r.byKind.get("reasoning")!.bytes).toBeLessThan(
ceiling("reasoning", SIZES.reasoningChars),
);
expect(r.byKind.get("streaming")!.bytes).toBeLessThan(
ceiling("streaming", SIZES.streamingChars),
);
});
it("growth is sublinear past the recent window — doubling turns barely doubles bytes", () => {
const r1k = measureCards(runSession(1000).cards);
const r2k = measureCards(runSession(2000).cards);
const ratio = r2k.totalBytes / r1k.totalBytes;
console.log(
`1000-turn=${(r1k.totalBytes / 1024 / 1024).toFixed(2)} MB, 2000-turn=${(r2k.totalBytes / 1024 / 1024).toFixed(2)} MB, ratio=${ratio.toFixed(2)}x`,
);
expect(ratio).toBeLessThan(1.5);
});
it("elides old tool arguments, not just old tool output", () => {
let state = initialState(session);
const turns = 400;
const argPayload = "input payload\n".repeat(2000);
const rawArgsBytes = bytesUtf8(JSON.stringify({ path: "big.txt", content: argPayload }));
for (let i = 0; i < turns; i++) {
const id = `tool-arg-${i}`;
state = reduce(state, {
type: "tool.start",
id,
name: "write_file",
args: { path: "big.txt", content: argPayload },
});
state = reduce(state, {
type: "tool.end",
id,
output: "ok",
elapsedMs: 1,
});
}
const r = measureCards(state.cards);
const retainedArgBytes = r.byField.get("tool.args") ?? 0;
expect(retainedArgBytes).toBeLessThan(rawArgsBytes * turns * 0.6);
});
it("actual V8 heap delta on a long session stays under 100 MB (smoke check)", () => {
if (typeof globalThis.gc !== "function") {
console.log("(skipped — run with NODE_OPTIONS=--expose-gc for real heap delta)");
return;
}
globalThis.gc();
const before = process.memoryUsage().heapUsed;
const state = runSession(1000);
globalThis.gc();
const after = process.memoryUsage().heapUsed;
const deltaMB = (after - before) / 1024 / 1024;
console.log(
`V8 heap delta over 1000 turns: ${deltaMB.toFixed(1)} MB, cards retained: ${state.cards.length}`,
);
expect(deltaMB).toBeLessThan(100);
});
});