test: add manual terminal perf harness

This commit is contained in:
Mohamed Boudra
2026-04-21 20:47:28 +07:00
parent 6a03873d81
commit c402db8b1c
6 changed files with 728 additions and 2 deletions
@@ -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<void>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -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<void> {
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
}
async function emitLargeDiffAgentPayload(
harness: TerminalE2EHarness,
input: { agentId: string; bytes: number },
): Promise<void> {
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<DaemonEchoReport> {
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<void> {
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<void> {
const tail = text.slice(-80);
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
}
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
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<void> {
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<void> {
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<void> {
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;
}
@@ -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<void> };
let workspaceId: string;
@@ -89,6 +89,16 @@ type ActiveTurn = {
completed: Promise<AgentRunResult>;
};
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;
@@ -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,
@@ -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 ?? {});