diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 4baaf89edb..5d3c836e65 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -1,4 +1,6 @@ -import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; @@ -251,23 +253,52 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); +async function measureTimelineTail(wholeTurns: boolean) { + const fixture = fileURLToPath( + new URL("./test-utils/timeline-responsiveness.ts", import.meta.url), + ); + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)], + { timeout: 15_000 }, + ); + process.stderr.write(stderr); + return JSON.parse(stdout) as { + result: ReturnType; + blockingMs: number; + }; +} + +it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => { + let unrelatedWork: ReturnType; + const burnCpu = () => { + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const start = threadCpuUsage(); + let elapsed: NodeJS.CpuUsage; + do { + elapsed = threadCpuUsage(start); + } while (elapsed.user + elapsed.system < 80_000); + unrelatedWork = setImmediate(burnCpu); + }; + unrelatedWork = setImmediate(burnCpu); + try { + const { result: tail, blockingMs } = await measureTimelineTail(false); + expect(tail.rows).toHaveLength(200); + expect(tail.startSeq).toBe(99_801); + expect(tail.endSeq).toBe(100_000); + expect(blockingMs).toBeLessThan(50); + } finally { + clearImmediate(unrelatedWork); + } +}); + it.each([false, true])( "serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)", async (wholeTurns) => { - const store = new InMemoryAgentTimelineStore(); - store.initialize("large"); - for (let index = 0; index < 100_000; index++) { - store.append("large", { type: "user_message", text: String(index) }); - } - const { result: tail, blockingMs } = await measureMainThreadWork( - `timeline tail main-thread work (wholeTurns=${wholeTurns})`, - () => { - let page = store.fetch("large", { limit: 200, wholeTurns }); - for (let repeat = 1; repeat < 8; repeat++) - page = store.fetch("large", { limit: 200, wholeTurns }); - return page; - }, - ); + const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns); expect(tail.rows).toHaveLength(200); expect(tail.startSeq).toBe(99_801); expect(tail.endSeq).toBe(100_000); diff --git a/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts b/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts new file mode 100644 index 0000000000..bfe5ee59c4 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts @@ -0,0 +1,25 @@ +import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; +import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js"; + +// Measure a cold fetch on its own thread, without Vitest reporting or callbacks. +const wholeTurns = process.argv[2] === "true"; +const store = new InMemoryAgentTimelineStore(); +store.initialize("large"); +for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); +} +// Finish fixture allocation and collect its temporary garbage before measuring. +globalThis.gc!(); +await nextTurn(); +const measured = await measureMainThreadWork( + `timeline tail main-thread work (wholeTurns=${wholeTurns})`, + () => { + let page = store.fetch("large", { limit: 200, wholeTurns }); + for (let repeat = 1; repeat < 8; repeat++) + page = store.fetch("large", { limit: 200, wholeTurns }); + return page; + }, +); + +process.stdout.write(JSON.stringify(measured));