diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index a47790057..394666551 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -19,6 +19,13 @@ export type TerminalPerfDaemonClient = { terminal: { id: string; name: string; cwd: string } | null; error: string | null; }>; + createAgent(options: { + provider: string; + cwd: string; + title?: string; + modeId?: string; + }): Promise<{ id: string; status: string }>; + sendAgentMessage(agentId: string, text: string): Promise; subscribeTerminal( terminalId: string, ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; diff --git a/packages/app/e2e/terminal-keystroke-stress.spec.ts b/packages/app/e2e/terminal-keystroke-stress.spec.ts new file mode 100644 index 000000000..6c56d4921 --- /dev/null +++ b/packages/app/e2e/terminal-keystroke-stress.spec.ts @@ -0,0 +1,350 @@ +import { performance } from "node:perf_hooks"; +import type { Page, TestInfo } from "@playwright/test"; +import { test, expect } from "./fixtures"; +import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl"; +import { + installTerminalKeystrokeStressProbe, + readTerminalKeystrokeStressReport, + resetTerminalKeystrokeStressProbe, + type LatencyStats, +} from "./helpers/terminal-probes"; +import { waitForTerminalContent } from "./helpers/terminal-perf"; + +const INPUT_TEXT = buildStressText(600); +const BIG_DIFF_BYTES = 256_000; +const SMALL_AGENT_STREAM_UPDATES = 1000; +const STRESS_TIMEOUT_MS = 15_000; +const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1"; +const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip; + +type DaemonEchoReport = { + inputTextLength: number; + inputFrameCount: number; + outputEventCount: number; + echoedBytes: number; + sendToOutputMs: LatencyStats; + firstSendAt: number; + lastOutputAt: number; +}; + +terminalPerfDescribe("Terminal keystroke stress", () => { + let harness: TerminalE2EHarness; + + test.beforeAll(async () => { + harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" }); + }); + + test.afterAll(async () => { + await harness?.cleanup(); + }); + + test("logs daemon-only and app keystroke echo latency under burst input", async ({ + page, + }, testInfo) => { + test.setTimeout(75_000); + + const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" }); + try { + const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT); + await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport); + console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport)); + + expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + } finally { + await harness.killTerminal(daemonTerminal.id); + } + + await installTerminalKeystrokeStressProbe(page); + const appBaselineReport = await measureAppBurstEcho({ + page, + harness, + terminalName: "app-keystroke-stress", + }); + await attachJson(testInfo, "app-keystroke-stress", appBaselineReport); + console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport)); + + expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0); + + const appSmallChunksReport = await measureAppBurstEcho({ + page, + harness, + terminalName: "app-keystroke-stress-small-agent-chunks", + agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES, + }); + await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport); + console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport)); + + expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0); + expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual( + SMALL_AGENT_STREAM_UPDATES, + ); + + const appBigDiffReport = await measureAppBurstEcho({ + page, + harness, + terminalName: "app-keystroke-stress-big-diff", + bigDiffBytes: BIG_DIFF_BYTES, + }); + await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport); + console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport)); + + expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); + expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0); + expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1); + expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual( + BIG_DIFF_BYTES, + ); + }); +}); + +async function measureAppBurstEcho(input: { + page: Page; + harness: TerminalE2EHarness; + terminalName: string; + bigDiffBytes?: number; + agentStreamUpdateCount?: number; +}) { + const appTerminal = await input.harness.createTerminal({ name: input.terminalName }); + try { + await input.harness.openTerminal(input.page, { terminalId: appTerminal.id }); + await input.harness.setupPrompt(input.page); + + const agent = + input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined + ? null + : await input.harness.client.createAgent({ + provider: "mock", + cwd: input.harness.tempRepo.path, + title: "Large WebSocket payload", + modeId: "load-test", + }); + + const bigDiffBytes = input.bigDiffBytes; + const agentStreamUpdateCount = input.agentStreamUpdateCount; + const startAgentLoad = + agent === null + ? null + : () => { + if (bigDiffBytes !== undefined) { + return emitLargeDiffAgentPayload(input.harness, { + agentId: agent.id, + bytes: bigDiffBytes, + }); + } + if (agentStreamUpdateCount !== undefined) { + return emitRapidAgentStreamUpdates(input.harness, { + agentId: agent.id, + count: agentStreamUpdateCount, + }); + } + return Promise.resolve(); + }; + + const terminal = input.harness.terminalSurface(input.page); + await terminal.press("Control+c"); + await input.page.waitForTimeout(200); + await resetTerminalKeystrokeStressProbe(input.page); + + const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad(); + + await terminal.pressSequentially(INPUT_TEXT, { delay: 0 }); + await waitForAppStressEcho(input.page, INPUT_TEXT); + await waitForAppProbePayload(input.page, INPUT_TEXT.length); + if (input.bigDiffBytes !== undefined) { + await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes); + } + if (input.agentStreamUpdateCount !== undefined) { + await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount); + } + await activeAgentLoadPromise; + + return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT); + } finally { + await input.harness.killTerminal(appTerminal.id); + } +} + +async function emitRapidAgentStreamUpdates( + harness: TerminalE2EHarness, + input: { agentId: string; count: number }, +): Promise { + await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`); +} + +async function emitLargeDiffAgentPayload( + harness: TerminalE2EHarness, + input: { agentId: string; bytes: number }, +): Promise { + await harness.client.sendAgentMessage( + input.agentId, + `emit ${input.bytes} byte large diff agent stream update`, + ); +} + +async function measureDaemonBurstEcho( + harness: TerminalE2EHarness, + terminal: TerminalInstance, + inputText: string, +): Promise { + await harness.client.subscribeTerminal(terminal.id); + + const outputTimesByByte: number[] = []; + let outputEventCount = 0; + let echoedBytes = 0; + const decoder = new TextDecoder(); + const unsubscribe = harness.client.onTerminalStreamEvent((event) => { + if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) { + return; + } + outputEventCount += 1; + const text = decoder.decode(event.data); + const now = performance.now(); + const previousEchoedBytes = echoedBytes; + echoedBytes += text.length; + for (let index = previousEchoedBytes; index < echoedBytes; index += 1) { + outputTimesByByte[index] = now; + } + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 250)); + echoedBytes = 0; + outputTimesByByte.length = 0; + + const sendTimes: number[] = []; + for (const char of inputText) { + sendTimes.push(performance.now()); + harness.client.sendTerminalInput(terminal.id, { + type: "input", + data: char, + }); + } + + await waitForDaemonEchoBytes({ + getEchoedBytes: () => echoedBytes, + expectedBytes: inputText.length, + timeoutMs: STRESS_TIMEOUT_MS, + }); + + const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt); + return { + inputTextLength: inputText.length, + inputFrameCount: sendTimes.length, + outputEventCount, + echoedBytes, + sendToOutputMs: summarizeLatency(latencies), + firstSendAt: sendTimes[0] ?? 0, + lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0, + }; + } finally { + unsubscribe(); + } +} + +async function waitForDaemonEchoBytes(input: { + getEchoedBytes: () => number; + expectedBytes: number; + timeoutMs: number; +}): Promise { + const deadline = Date.now() + input.timeoutMs; + while (Date.now() < deadline) { + if (input.getEchoedBytes() >= input.expectedBytes) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error( + `Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`, + ); +} + +async function waitForAppStressEcho(page: Page, text: string): Promise { + const tail = text.slice(-80); + await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS); +} + +async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise { + const deadline = Date.now() + STRESS_TIMEOUT_MS; + while (Date.now() < deadline) { + const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT); + if (report.outputFramePayloadBytes >= expectedBytes) { + return; + } + await page.waitForTimeout(25); + } +} + +async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise { + const deadline = Date.now() + STRESS_TIMEOUT_MS; + while (Date.now() < deadline) { + const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT); + if (report.largestAgentStreamTextMessageBytes >= expectedBytes) { + return; + } + await page.waitForTimeout(25); + } + const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT); + throw new Error( + `Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`, + ); +} + +async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise { + const deadline = Date.now() + STRESS_TIMEOUT_MS; + while (Date.now() < deadline) { + const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT); + if (report.agentStreamTextMessageCount >= expectedCount) { + return; + } + await page.waitForTimeout(25); + } + const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT); + throw new Error( + `Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`, + ); +} + +async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise { + await testInfo.attach(name, { + body: JSON.stringify(value, null, 2), + contentType: "application/json", + }); +} + +function buildStressText(length: number): string { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let output = ""; + while (output.length < length) { + output += alphabet; + } + return output.slice(0, length); +} + +function summarizeLatency(values: number[]): LatencyStats { + const sorted = [...values].sort((a, b) => a - b); + const percentile = (p: number) => { + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0; + }; + const total = values.reduce((sum, value) => sum + value, 0); + return { + count: values.length, + minMs: round2(sorted[0] ?? 0), + p50Ms: round2(percentile(50)), + p95Ms: round2(percentile(95)), + maxMs: round2(sorted[sorted.length - 1] ?? 0), + avgMs: round2(total / values.length), + }; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/terminal-performance.spec.ts index 1392ca4ac..307ee5637 100644 --- a/packages/app/e2e/terminal-performance.spec.ts +++ b/packages/app/e2e/terminal-performance.spec.ts @@ -16,8 +16,10 @@ const LINE_COUNT = 50_000; const THROUGHPUT_BUDGET_MS = 30_000; const KEYSTROKE_SAMPLE_COUNT = 20; const KEYSTROKE_P95_BUDGET_MS = 150; +const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1"; +const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip; -test.describe("Terminal wire performance", () => { +terminalPerfDescribe("Terminal wire performance", () => { let client: TerminalPerfDaemonClient; let tempRepo: { path: string; cleanup: () => Promise }; let workspaceId: string; diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index ff65cc315..e82ade211 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -89,6 +89,16 @@ type ActiveTurn = { completed: Promise; }; +type LargeAgentStreamPayloadRequest = { + bytes: number; + kind: "diff" | "file" | "image"; +}; + +type AgentStreamStressRequest = { + count: number; + coalesced: boolean; +}; + function resolveModelProfile(modelId: string | null | undefined): { modelId: string; durationMs: number; @@ -115,6 +125,52 @@ function promptToText(prompt: AgentPromptInput): string { .trim(); } +function parseLargeAgentStreamPayloadPrompt( + prompt: AgentPromptInput, +): LargeAgentStreamPayloadRequest | null { + const text = promptToText(prompt); + const match = + /emit\s+(\d+)\s+(?:byte\s+)?(?:large\s+)?(diff|file|image)\s+agent stream (?:update|payload)/i.exec( + text, + ); + if (!match) { + return null; + } + const bytes = Number(match[1]); + if (!Number.isFinite(bytes) || bytes <= 0) { + return null; + } + return { + bytes: Math.min(bytes, 1_000_000), + kind: match[2]?.toLowerCase() as LargeAgentStreamPayloadRequest["kind"], + }; +} + +function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null { + const text = promptToText(prompt); + const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text); + if (!match) { + return null; + } + const count = Number(match[1]); + if (!Number.isFinite(count) || count <= 0) { + return null; + } + return { + count: Math.min(count, 5_000), + coalesced: Boolean(match[2]), + }; +} + +function buildRepeatedPayload(bytes: number, prefix: string): string { + const line = `${prefix} ${"x".repeat(96)}\n`; + let output = ""; + while (output.length < bytes) { + output += line; + } + return output.slice(0, bytes); +} + function buildMarkdownDocument(iteration: number, prompt: AgentPromptInput): string { const promptPreview = promptToText(prompt).slice(0, 160) || "No prompt text supplied."; return [ @@ -282,7 +338,15 @@ export class MockLoadTestAgentSession implements AgentSession { }; this.activeTurn = turn; - this.schedule(turn, 0); + const largePayload = parseLargeAgentStreamPayloadPrompt(prompt); + const stress = parseAgentStreamStressPrompt(prompt); + if (largePayload) { + this.scheduleLargePayloadTurn(turn, largePayload); + } else if (stress) { + this.scheduleStressTurn(turn, stress); + } else { + this.schedule(turn, 0); + } return { turnId }; } @@ -380,6 +444,147 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleLargePayloadTurn( + turn: ActiveTurn, + largePayload: LargeAgentStreamPayloadRequest, + ): void { + turn.timer = setTimeout(() => { + this.emitLargePayloadTurn(turn, largePayload); + }, 0); + turn.timer.unref?.(); + } + + private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { + turn.timer = setTimeout(() => { + this.emitStressTurn(turn, stress); + }, 0); + turn.timer.unref?.(); + } + + private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { + if (this.activeTurn !== turn) { + return; + } + + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + + for (let index = 0; index < stress.count; index += 1) { + this.emitTimeline( + turn.turnId, + stress.coalesced + ? { + type: "assistant_message", + text: `stress-update-${index}`, + } + : { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }, + ); + } + + this.activeTurn = null; + const usage = { + inputTokens: 1, + outputTokens: stress.count, + contextWindowUsedTokens: stress.count, + contextWindowMaxTokens: 128_000, + }; + this.emit({ + type: "turn_completed", + provider: this.provider, + turnId: turn.turnId, + usage, + }); + turn.resolve({ + sessionId: this.id, + finalText: "Synthetic agent stream stress complete", + usage, + timeline: [], + canceled: false, + }); + } + + private emitLargePayloadTurn( + turn: ActiveTurn, + largePayload: LargeAgentStreamPayloadRequest, + ): void { + if (this.activeTurn !== turn) { + return; + } + + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + + const payload = buildRepeatedPayload(largePayload.bytes, largePayload.kind); + if (largePayload.kind === "diff") { + this.emitTimeline( + turn.turnId, + createToolCall({ + turnId: turn.turnId, + iteration: 0, + name: "edit", + status: "completed", + detail: { + type: "edit", + filePath: "src/large-diff.ts", + unifiedDiff: `diff --git a/src/large-diff.ts b/src/large-diff.ts\n${payload}`, + }, + }), + ); + } else if (largePayload.kind === "file") { + this.emitTimeline( + turn.turnId, + createToolCall({ + turnId: turn.turnId, + iteration: 0, + name: "read", + status: "completed", + detail: { + type: "read", + filePath: "src/large-file.txt", + content: payload, + }, + }), + ); + } else { + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: `data:image/png;base64,${payload}`, + }); + } + + this.activeTurn = null; + const usage = { + inputTokens: 1, + outputTokens: largePayload.bytes, + contextWindowUsedTokens: largePayload.bytes, + contextWindowMaxTokens: 128_000, + }; + this.emit({ + type: "turn_completed", + provider: this.provider, + turnId: turn.turnId, + usage, + }); + turn.resolve({ + sessionId: this.id, + finalText: "Synthetic large payload complete", + usage, + timeline: [], + canceled: false, + }); + } + private tick(turn: ActiveTurn): void { if (this.activeTurn !== turn) { return; diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 3c822196b..a3d4d2278 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -376,6 +376,16 @@ function getFrameText(frame: TerminalStreamFrame): string { return ""; } +function sendRawTerminalInput(input: { ws: WebSocket; slot: number; text: string }): void { + input.ws.send( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + slot: input.slot, + payload: new Uint8Array(Buffer.from(input.text, "utf8")), + }), + ); +} + async function subscribeRawTerminal( ws: WebSocket, terminalId: string, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index a370b0aa4..6d0cce1ce 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -181,6 +181,107 @@ function buildToolCallForPrompt(provider: string, prompt: string) { return null; } +function parseAgentStreamStressPrompt(prompt: string): { + count: number; + coalesced: boolean; +} | null { + const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(prompt); + if (!match) { + return null; + } + const count = Number(match[1]); + if (!Number.isFinite(count) || count <= 0) { + return null; + } + return { + count: Math.min(count, 5000), + coalesced: Boolean(match[2]), + }; +} + +function parseLargeAgentStreamPayloadPrompt(prompt: string): { + bytes: number; + kind: "diff" | "file" | "image"; +} | null { + const match = + /emit\s+(\d+)\s+(?:byte\s+)?(?:large\s+)?(diff|file|image)\s+agent stream (?:update|payload)/i.exec( + prompt, + ); + if (!match) { + return null; + } + const bytes = Number(match[1]); + if (!Number.isFinite(bytes) || bytes <= 0) { + return null; + } + return { + bytes: Math.min(bytes, 1_000_000), + kind: match[2]?.toLowerCase() as "diff" | "file" | "image", + }; +} + +function buildRepeatedPayload(bytes: number, prefix: string): string { + const line = `${prefix} ${"x".repeat(96)}\n`; + let output = ""; + while (output.length < bytes) { + output += line; + } + return output.slice(0, bytes); +} + +function buildLargeTimelineItem(input: { + bytes: number; + kind: "diff" | "file" | "image"; + callId: string; + provider: AgentStreamEvent["provider"]; +}): AgentStreamEvent { + const payload = buildRepeatedPayload(input.bytes, input.kind); + if (input.kind === "diff") { + return { + type: "timeline", + provider: input.provider, + item: { + type: "tool_call", + name: "apply_patch", + callId: input.callId, + status: "completed", + detail: { + type: "edit", + filePath: "src/large-diff.ts", + unifiedDiff: `diff --git a/src/large-diff.ts b/src/large-diff.ts\n${payload}`, + }, + error: null, + }, + }; + } + if (input.kind === "file") { + return { + type: "timeline", + provider: input.provider, + item: { + type: "tool_call", + name: "read_file", + callId: input.callId, + status: "completed", + detail: { + type: "read", + filePath: "src/large-file.txt", + content: payload, + }, + error: null, + }, + }; + } + return { + type: "timeline", + provider: input.provider, + item: { + type: "assistant_message", + text: `data:image/png;base64,${payload}`, + }, + }; +} + class FakeAgentSession implements AgentSession { readonly capabilities = TEST_CAPABILITIES; readonly id: string; @@ -372,6 +473,57 @@ class FakeAgentSession implements AgentSession { await this.appendHistoryEvent(turnStarted); this.notifySubscribers(turnStarted); + const stress = parseAgentStreamStressPrompt(textPrompt); + if (stress !== null) { + for (let index = 0; index < stress.count; index += 1) { + const stressUpdate: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: stress.coalesced + ? { + type: "assistant_message", + text: `stress-update-${index}`, + } + : { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }, + }; + await this.appendHistoryEvent(stressUpdate); + this.notifySubscribers(stressUpdate); + await new Promise((resolve) => setImmediate(resolve)); + } + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: stress.count }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + return; + } + + const largePayload = parseLargeAgentStreamPayloadPrompt(textPrompt); + if (largePayload !== null) { + const largeUpdate = buildLargeTimelineItem({ + ...largePayload, + callId: randomUUID(), + provider: this.providerName, + }); + await this.appendHistoryEvent(largeUpdate); + this.notifySubscribers(largeUpdate); + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: largePayload.bytes }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + return; + } + const tool = buildToolCallForPrompt(this.providerName, textPrompt); if (tool) { const needsPermission = this.needsPermissionForTool(tool.name, tool.input ?? {});