Files
openclaw/extensions/copilot/src/event-bridge.test.ts
Ramrajprabu f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00

829 lines
25 KiB
TypeScript

import type { SessionEvent } from "@github/copilot-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { attachEventBridge, type SessionLike } from "./event-bridge.js";
const MODEL_REF = {
api: "openai-responses",
id: "gpt-5",
provider: "github-copilot",
} as const;
const REGISTERED_EVENT_TYPES = [
"assistant.message_delta",
"assistant.reasoning_delta",
"assistant.message",
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.error",
"abort",
] as const;
type FakeSession = SessionLike & {
emit: (eventType: string, event: SessionEvent) => void;
listenerCount: (eventType: string) => number;
};
function createDeferred<T>() {
let rejectPromise: ((reason?: unknown) => void) | undefined;
let resolvePromise: ((value: T | PromiseLike<T>) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return {
promise,
reject(reason?: unknown) {
rejectPromise?.(reason);
},
resolve(value: T) {
resolvePromise?.(value);
},
};
}
function flushAsync() {
// oxlint-disable-next-line unicorn/no-useless-promise-resolve-reject -- the inner Promise.resolve() forces an additional microtask tick so delta-chain ordering can be observed deterministically in tests.
return Promise.resolve().then(() => Promise.resolve());
}
function makeEvent(type: string, data: Record<string, unknown>): SessionEvent {
return {
data,
id: `${type}-id`,
parentId: null,
timestamp: "2024-01-01T00:00:00.000Z",
type,
} as SessionEvent;
}
function makeAssistantMessageEvent(
content = "assistant text",
overrides: Record<string, unknown> = {},
): SessionEvent {
return makeEvent("assistant.message", {
content,
messageId: "msg-1",
model: "gpt-5",
...overrides,
});
}
function createFakeSession(
options: {
onOff?: (eventType: string) => void;
onReturnedUnsubscribe?: (eventType: string) => void;
returnUnsubscribe?: boolean;
} = {},
): FakeSession {
const listeners = new Map<string, Array<(event: SessionEvent) => void>>();
const returnUnsubscribe = options.returnUnsubscribe !== false;
const off = vi.fn((eventType: string, handler: (event: SessionEvent) => void) => {
options.onOff?.(eventType);
listeners.set(
eventType,
(listeners.get(eventType) ?? []).filter((existing) => existing !== handler),
);
});
const on = vi.fn((eventType: string, handler: (event: SessionEvent) => void) => {
listeners.set(eventType, [...(listeners.get(eventType) ?? []), handler]);
if (!returnUnsubscribe) {
return undefined;
}
return () => {
options.onReturnedUnsubscribe?.(eventType);
off(eventType, handler);
};
});
return {
abort: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
emit(eventType: string, event: SessionEvent) {
for (const handler of listeners.get(eventType) ?? []) {
handler(event);
}
},
id: "session-id",
listenerCount(eventType: string) {
return listeners.get(eventType)?.length ?? 0;
},
off,
on,
sendAndWait: vi.fn().mockResolvedValue(undefined),
sessionId: "sdk-session-id",
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("attachEventBridge", () => {
it("assistant.message_delta accumulates text per messageId in arrival order", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "he", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "llo", messageId: "msg-1" }),
);
expect(bridge.snapshot().assistantTexts).toEqual(["hello"]);
});
it("interleaved messageIds produce two ordered assistantTexts entries", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "a", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "x", messageId: "msg-2" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "b", messageId: "msg-1" }),
);
expect(bridge.snapshot().assistantTexts).toEqual(["ab", "x"]);
});
it("onAssistantDelta receives appended text, live sessionId, and current usage", async () => {
const session = createFakeSession();
let sdkSessionId = "sdk-session-1";
const onAssistantDelta = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => sdkSessionId,
isAborted: () => false,
onAssistantDelta,
});
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
cacheReadTokens: 1,
cacheWriteTokens: 2,
inputTokens: 3,
outputTokens: 4,
}),
);
sdkSessionId = "sdk-session-2";
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "hi", messageId: "msg-1" }),
);
await bridge.awaitDeltaChain();
expect(onAssistantDelta).toHaveBeenCalledTimes(1);
expect(onAssistantDelta).toHaveBeenCalledWith({
delta: "hi",
sessionId: "sdk-session-2",
text: "hi",
usage: {
cacheRead: 1,
cacheWrite: 2,
input: 3,
output: 4,
total: 10,
},
});
});
it("onAssistantDelta callbacks are serialized and awaitDeltaChain resolves after both", async () => {
const session = createFakeSession();
const order: string[] = [];
const releases: Array<() => void> = [];
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta: vi.fn(async (payload: { delta: string }) => {
order.push(`start:${payload.delta}`);
await new Promise<void>((resolve) => {
releases.push(() => {
order.push(`end:${payload.delta}`);
resolve();
});
});
}),
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "a", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "b", messageId: "msg-1" }),
);
await flushAsync();
expect(order).toEqual(["start:a"]);
releases[0]?.();
await flushAsync();
expect(order).toEqual(["start:a", "end:a", "start:b"]);
releases[1]?.();
await expect(bridge.awaitDeltaChain()).resolves.toBeUndefined();
expect(order).toEqual(["start:a", "end:a", "start:b", "end:b"]);
});
it("onAssistantDelta rejection propagates through awaitDeltaChain while later deltas still serialize", async () => {
const session = createFakeSession();
const order: string[] = [];
const firstError = new Error("delta failed");
const secondDeferred = createDeferred<void>();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta: vi.fn((payload: { delta: string }) => {
order.push(`start:${payload.delta}`);
if (payload.delta === "a") {
return Promise.reject(firstError);
}
return secondDeferred.promise.then(() => {
order.push(`end:${payload.delta}`);
});
}),
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "a", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "b", messageId: "msg-1" }),
);
await flushAsync();
await flushAsync();
expect(order).toEqual(["start:a", "start:b"]);
secondDeferred.resolve(undefined);
await expect(bridge.awaitDeltaChain()).rejects.toBe(firstError);
expect(order).toEqual(["start:a", "start:b", "end:b"]);
});
it("assistant.reasoning_delta accumulates reasoning in arrival order for buildAssistantMessage", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "thin", reasoningId: "reason-1" }),
);
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "king", reasoningId: "reason-1" }),
);
bridge.recordSendResult(makeAssistantMessageEvent("done"));
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 7 })?.content).toEqual([
{ thinking: "thinking", type: "thinking" },
{ text: "done", type: "text" },
]);
});
it("buildAssistantMessage prefers terminal reasoningText over reasoning deltas", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "older", reasoningId: "reason-1" }),
);
bridge.recordSendResult(
makeAssistantMessageEvent("done", {
reasoningText: "terminal reasoning",
}),
);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 8 })?.content).toEqual([
{ thinking: "terminal reasoning", type: "thinking" },
{ text: "done", type: "text" },
]);
});
it("assistant.message only overwrites accumulated text when content is at least as long", () => {
const shorterSession = createFakeSession();
const shorterBridge = attachEventBridge(shorterSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
shorterSession.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "longer", messageId: "msg-1" }),
);
shorterSession.emit(
"assistant.message",
makeAssistantMessageEvent("short", { messageId: "msg-1" }),
);
const longerSession = createFakeSession();
const longerBridge = attachEventBridge(longerSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
longerSession.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "tiny", messageId: "msg-1" }),
);
longerSession.emit(
"assistant.message",
makeAssistantMessageEvent("longer text", { messageId: "msg-1" }),
);
expect(shorterBridge.finalizeAssistantTexts()).toEqual(["longer"]);
expect(longerBridge.finalizeAssistantTexts()).toEqual(["longer text"]);
});
it("assistant.message with toolRequests produces toolCall content and toolUse stopReason", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.recordSendResult(
makeAssistantMessageEvent("call tool", {
outputTokens: 7,
toolRequests: [
{
arguments: { path: "README.md" },
name: "read_file",
toolCallId: "call-1",
},
],
}),
);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 9 })).toEqual({
api: "openai-responses",
content: [
{ text: "call tool", type: "text" },
{
arguments: { path: "README.md" },
id: "call-1",
name: "read_file",
type: "toolCall",
},
],
model: "gpt-5",
provider: "github-copilot",
role: "assistant",
stopReason: "toolUse",
timestamp: 9,
usage: {
cacheRead: 0,
cacheWrite: 0,
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0,
total: 0,
},
input: 0,
output: 7,
totalTokens: 7,
},
});
});
it("assistant.usage updates internal usage and the next onAssistantDelta payload reads it", async () => {
const session = createFakeSession();
const onAssistantDelta = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta,
});
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
cacheReadTokens: -2,
cacheWriteTokens: Number.NaN,
inputTokens: 4.9,
outputTokens: 5.1,
}),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "x", messageId: "msg-1" }),
);
await bridge.awaitDeltaChain();
expect(onAssistantDelta).toHaveBeenCalledWith({
delta: "x",
sessionId: "sdk-session-id",
text: "x",
usage: {
cacheRead: 0,
cacheWrite: undefined,
input: 4,
output: 5,
total: 9,
},
});
});
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.recordSendResult(makeAssistantMessageEvent("done", { outputTokens: 7 }));
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
cacheReadTokens: "bad",
cacheWriteTokens: Number.POSITIVE_INFINITY,
inputTokens: undefined,
outputTokens: Number.NaN,
}),
);
expect(bridge.snapshot().usage).toEqual({
cacheRead: undefined,
cacheWrite: undefined,
input: undefined,
output: undefined,
total: 0,
});
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 9.5 })?.usage).toEqual({
cacheRead: 0,
cacheWrite: 0,
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0,
total: 0,
},
input: 0,
output: 0,
totalTokens: 0,
});
});
it("overwrites prior usage with an all-zero snapshot when a later invalid usage event arrives", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
inputTokens: 5,
}),
);
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
inputTokens: "bad",
}),
);
expect(bridge.snapshot().usage).toEqual({
cacheRead: undefined,
cacheWrite: undefined,
input: undefined,
output: undefined,
total: 0,
});
});
it("tool.execution_start increments startedCount and pushes toolMetas without meta", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-1", toolName: "bash" }),
);
expect(bridge.snapshot()).toEqual({
assistantTexts: [],
completedCount: 0,
lastAssistantEvent: undefined,
startedCount: 1,
streamError: undefined,
toolMetas: [{ toolName: "bash" }],
usage: undefined,
});
});
it("tool.execution_complete uses detailedContent or content on success and error.message on failure", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-1", toolName: "bash" }),
);
session.emit(
"tool.execution_complete",
makeEvent("tool.execution_complete", {
result: { content: "content", detailedContent: "details" },
success: true,
toolCallId: "call-1",
}),
);
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-2", toolName: "read" }),
);
session.emit(
"tool.execution_complete",
makeEvent("tool.execution_complete", {
error: { message: "failed" },
success: false,
toolCallId: "call-2",
}),
);
expect(bridge.snapshot().toolMetas).toEqual([
{ toolName: "bash" },
{ meta: "details", toolName: "bash" },
{ toolName: "read" },
{ meta: "failed", toolName: "read" },
]);
});
it("tool.execution_complete without a matching start increments completedCount without pushing meta", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"tool.execution_complete",
makeEvent("tool.execution_complete", {
result: { content: "done" },
success: true,
toolCallId: "missing",
}),
);
expect(bridge.snapshot().completedCount).toBe(1);
expect(bridge.snapshot().toolMetas).toEqual([]);
});
it("session.error populates streamError with errorCode or errorType only when not aborted", () => {
const activeSession = createFakeSession();
const activeBridge = attachEventBridge(activeSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
activeSession.emit(
"session.error",
makeEvent("session.error", {
errorCode: "boom_code",
errorType: "boom_type",
message: "boom",
}),
);
const abortedSession = createFakeSession();
const abortedBridge = attachEventBridge(abortedSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => true,
});
abortedSession.emit(
"session.error",
makeEvent("session.error", {
errorType: "ignored",
message: "ignored",
}),
);
expect((activeBridge.snapshot().streamError as Error & { code?: string })?.code).toBe(
"boom_code",
);
expect(activeBridge.snapshot().streamError?.message).toBe("boom");
expect(abortedBridge.snapshot().streamError).toBeUndefined();
});
it("abort populates streamError with session_aborted only when not aborted", () => {
const activeSession = createFakeSession();
const activeBridge = attachEventBridge(activeSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
activeSession.emit("abort", makeEvent("abort", { reason: "because" }));
const abortedSession = createFakeSession();
const abortedBridge = attachEventBridge(abortedSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => true,
});
abortedSession.emit("abort", makeEvent("abort", { reason: "ignored" }));
expect((activeBridge.snapshot().streamError as Error & { code?: string })?.code).toBe(
"session_aborted",
);
expect(activeBridge.snapshot().streamError?.message).toBe(
"[copilot-attempt] session aborted: because",
);
expect(abortedBridge.snapshot().streamError).toBeUndefined();
});
it("recordSendResult returns false for undefined and true for assistant.message while updating lastAssistantEvent", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
expect(bridge.recordSendResult(undefined)).toBe(false);
const event = makeAssistantMessageEvent("done", { outputTokens: 2 });
expect(bridge.recordSendResult(event)).toBe(true);
expect(bridge.snapshot().lastAssistantEvent).toEqual(event);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 11 })?.content).toEqual([
{ text: "done", type: "text" },
]);
});
it("recordSendResult falls back to terminal content when no deltas arrived", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.recordSendResult(makeAssistantMessageEvent("done"));
expect(bridge.finalizeAssistantTexts()).toEqual(["done"]);
});
it("ignores empty assistant and reasoning deltas", () => {
const onAssistantDelta = vi.fn();
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "", messageId: "msg-1" }),
);
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "", reasoningId: "reason-1" }),
);
session.emit("assistant.message", makeAssistantMessageEvent("", { messageId: "msg-1" }));
expect(onAssistantDelta).not.toHaveBeenCalled();
expect(bridge.finalizeAssistantTexts()).toEqual([]);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 13 })).toBeUndefined();
});
it("detach is idempotent after the first unsubscribe pass", () => {
const order: string[] = [];
const session = createFakeSession({
onReturnedUnsubscribe: (eventType) => {
order.push(eventType);
},
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.detach();
bridge.detach();
expect(order).toEqual([...REGISTERED_EVENT_TYPES].toReversed());
expect(session.off).toHaveBeenCalledTimes(REGISTERED_EVENT_TYPES.length);
});
it("detach unsubscribes in reverse order when session.on returns unsubscribe functions", () => {
const order: string[] = [];
const session = createFakeSession({
onReturnedUnsubscribe: (eventType) => {
order.push(eventType);
},
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.detach();
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "ignored", messageId: "msg-1" }),
);
expect(order).toEqual([...REGISTERED_EVENT_TYPES].toReversed());
expect(session.listenerCount("assistant.message_delta")).toBe(0);
});
it("detach unsubscribes in reverse order via off() fallback", () => {
const order: string[] = [];
const session = createFakeSession({
onOff: (eventType) => {
order.push(eventType);
},
returnUnsubscribe: false,
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.detach();
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "ignored", messageId: "msg-1" }),
);
expect(order).toEqual([...REGISTERED_EVENT_TYPES].toReversed());
expect(session.listenerCount("assistant.message_delta")).toBe(0);
});
it("buildAssistantMessage returns undefined with no event, text, reasoning, or toolRequests", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 12 })).toBeUndefined();
});
it("snapshot returns defensive copies for arrays and usage objects", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "hello", messageId: "msg-1" }),
);
session.emit(
"assistant.usage",
makeEvent("assistant.usage", { inputTokens: 1, outputTokens: 2 }),
);
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-1", toolName: "bash" }),
);
const first = bridge.snapshot();
(first.assistantTexts as string[]).push("mutated");
(first.toolMetas as Array<{ meta?: string; toolName: string }>)[0].toolName = "mutated";
(first.usage as { input?: number }).input = 999;
const second = bridge.snapshot();
expect(second.assistantTexts).toEqual(["hello"]);
expect(second.toolMetas).toEqual([{ toolName: "bash" }]);
expect(second.usage).toEqual({
cacheRead: undefined,
cacheWrite: undefined,
input: 1,
output: 2,
total: 3,
});
});
});