Skip to content

Commit 549f9e3

Browse files
committed
test(sdk): unit tests for ChatHandle (usage agg, permission, lifecycle)
19 tests covering the parts of ChatHandle that are easy to get subtly wrong without a live harness: Usage aggregation - input/output tokens always SUM across snapshots - cache creation + cache read tokens SUM - cost cumulative semantic → MAX - cost delta semantic → SUM - undefined semantic defaults to cumulative (safe) - mixed cumulative + delta in one turn → prefer cumulative (no double-count) - no cost snapshots → costUsd is undefined - getUsage() consistent mid-stream and after drain Dual iteration interface - for-await yields events in order - await handle drains to ChatResult - result() is memoized (no re-iteration) - drain() throws if stream closes without ca_session_ended Permission callback - onPermissionRequest called with exact event args; decision POSTed - default decision is allow when no hook provided - custom deny decision is forwarded Lifecycle - onComplete fires after ca_session_ended (once) - onComplete NOT fired when stream never ends - sdk_message payloads collected into result.messages in order - cancel() POSTs /cancel for the right session All assertions use vi.fn mocks — no network, no harness, no subprocess. Suite runs in <25ms. Full SDK suite: 73 passing, 8 skipped (offline), 0 failures.
1 parent a159178 commit 549f9e3

1 file changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
/**
2+
* Unit tests for `ChatHandle` — exercise the parts that are easy to get subtly
3+
* wrong with no live harness:
4+
*
5+
* - Usage aggregation: tokens always SUM; cost respects `costSemantic`
6+
* (cumulative → MAX, delta → SUM, mixed → prefer cumulative).
7+
* - The dual interface: `for await` yields raw events; `await handle`
8+
* resolves to a ChatResult. Both consume the same stream once.
9+
* - Permission callback contract: `onPermissionRequest` is called with the
10+
* exact args from the event, and the resolved decision is POSTed back to
11+
* the harness. Default decision is "allow".
12+
* - Lifecycle: `onComplete` fires after `ca_session_ended`; `drain()`
13+
* throws when the stream closes without an end event; `result()` is
14+
* memoized.
15+
*
16+
* Everything is mocked — no network, no harness — so the suite runs in <50ms.
17+
*/
18+
import { describe, it, expect, vi } from "vitest";
19+
import type { HarnessEvent } from "@open-gitagent/protocol";
20+
import { ChatHandle } from "./chat-handle.js";
21+
import type { PermissionDecision } from "./types.js";
22+
23+
const SESS = "sess_test_01";
24+
const HARNESS = "http://localhost:9999";
25+
26+
/** Tiny helper: turn a fixed list of events into an AsyncIterable<HarnessEvent>. */
27+
async function* iter(events: HarnessEvent[]): AsyncIterable<HarnessEvent> {
28+
for (const ev of events) yield ev;
29+
}
30+
31+
/** Build a ChatHandle wired to a deterministic event stream + a fetch mock. */
32+
function makeHandle(
33+
events: HarnessEvent[],
34+
extra: {
35+
onPermissionRequest?: (
36+
callId: string,
37+
toolName: string,
38+
input: unknown,
39+
risk?: "low" | "medium" | "high" | "destructive",
40+
) => Promise<PermissionDecision> | PermissionDecision;
41+
onComplete?: () => Promise<void> | void;
42+
} = {},
43+
): { handle: ChatHandle; fetchImpl: ReturnType<typeof vi.fn> } {
44+
const fetchImpl = vi.fn(async () =>
45+
new Response("", { status: 200, headers: { "content-type": "application/json" } }),
46+
);
47+
const handle = new ChatHandle({
48+
sessionIdPromise: Promise.resolve(SESS),
49+
events: iter(events),
50+
harnessUrlPromise: Promise.resolve(HARNESS),
51+
fetchImpl: fetchImpl as unknown as typeof fetch,
52+
...extra,
53+
});
54+
return { handle, fetchImpl };
55+
}
56+
57+
const endedEvent = (): HarnessEvent =>
58+
({ kind: "ca_session_ended", sessionId: SESS, reason: "complete" } as HarnessEvent);
59+
60+
describe("ChatHandle — usage aggregation", () => {
61+
it("sums input/output tokens across snapshots", async () => {
62+
const { handle } = makeHandle([
63+
{ kind: "ca_usage_snapshot", sessionId: SESS, inputTokens: 100, outputTokens: 30 } as HarnessEvent,
64+
{ kind: "ca_usage_snapshot", sessionId: SESS, inputTokens: 50, outputTokens: 12 } as HarnessEvent,
65+
endedEvent(),
66+
]);
67+
const result = await handle;
68+
expect(result.usage.inputTokens).toBe(150);
69+
expect(result.usage.outputTokens).toBe(42);
70+
});
71+
72+
it("sums cache creation + cache read tokens", async () => {
73+
const { handle } = makeHandle([
74+
{ kind: "ca_usage_snapshot", sessionId: SESS, cacheCreationInputTokens: 200, cacheReadInputTokens: 800 } as HarnessEvent,
75+
{ kind: "ca_usage_snapshot", sessionId: SESS, cacheCreationInputTokens: 50, cacheReadInputTokens: 100 } as HarnessEvent,
76+
endedEvent(),
77+
]);
78+
const result = await handle;
79+
expect(result.usage.cacheCreationInputTokens).toBe(250);
80+
expect(result.usage.cacheReadInputTokens).toBe(900);
81+
});
82+
83+
it("cost: cumulative semantic → takes the MAX across snapshots", async () => {
84+
// claude-agent-sdk emits a running cumulative total; we should latch onto
85+
// the largest value seen (typically the last one).
86+
const { handle } = makeHandle([
87+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.01, costSemantic: "cumulative" } as HarnessEvent,
88+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.04, costSemantic: "cumulative" } as HarnessEvent,
89+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.03, costSemantic: "cumulative" } as HarnessEvent,
90+
endedEvent(),
91+
]);
92+
const result = await handle;
93+
expect(result.usage.costUsd).toBe(0.04);
94+
});
95+
96+
it("cost: delta semantic → SUMs per-message values", async () => {
97+
// gitclaw emits per-message deltas; we should add them up.
98+
const { handle } = makeHandle([
99+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.01, costSemantic: "delta" } as HarnessEvent,
100+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.02, costSemantic: "delta" } as HarnessEvent,
101+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.005, costSemantic: "delta" } as HarnessEvent,
102+
endedEvent(),
103+
]);
104+
const result = await handle;
105+
expect(result.usage.costUsd).toBeCloseTo(0.035, 5);
106+
});
107+
108+
it("cost: undefined semantic is treated as cumulative (defensive)", async () => {
109+
const { handle } = makeHandle([
110+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.07 } as HarnessEvent,
111+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.04 } as HarnessEvent,
112+
endedEvent(),
113+
]);
114+
const result = await handle;
115+
// MAX, not SUM
116+
expect(result.usage.costUsd).toBe(0.07);
117+
});
118+
119+
it("cost: mixed cumulative + delta in one turn → prefer cumulative (no double-count)", async () => {
120+
// Defensive case — shouldn't happen in practice but the SDK has explicit
121+
// handling and a documented preference. Pin the behavior.
122+
const { handle } = makeHandle([
123+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.10, costSemantic: "cumulative" } as HarnessEvent,
124+
{ kind: "ca_usage_snapshot", sessionId: SESS, costUsd: 0.02, costSemantic: "delta" } as HarnessEvent,
125+
endedEvent(),
126+
]);
127+
const result = await handle;
128+
expect(result.usage.costUsd).toBe(0.10);
129+
});
130+
131+
it("no cost snapshots → costUsd is undefined", async () => {
132+
const { handle } = makeHandle([
133+
{ kind: "ca_usage_snapshot", sessionId: SESS, inputTokens: 100, outputTokens: 30 } as HarnessEvent,
134+
endedEvent(),
135+
]);
136+
const result = await handle;
137+
expect(result.usage.costUsd).toBeUndefined();
138+
expect(result.usage.inputTokens).toBe(100);
139+
});
140+
141+
it("getUsage() returns the same rollup mid-stream and after drain", async () => {
142+
const { handle } = makeHandle([
143+
{ kind: "ca_usage_snapshot", sessionId: SESS, inputTokens: 10, costUsd: 0.01, costSemantic: "cumulative" } as HarnessEvent,
144+
{ kind: "sdk_message", sessionId: SESS, payload: {} } as HarnessEvent,
145+
{ kind: "ca_usage_snapshot", sessionId: SESS, inputTokens: 5, costUsd: 0.05, costSemantic: "cumulative" } as HarnessEvent,
146+
endedEvent(),
147+
]);
148+
let midSnapshotInput = 0;
149+
for await (const ev of handle) {
150+
if (ev.kind === "sdk_message") {
151+
midSnapshotInput = handle.getUsage().inputTokens;
152+
}
153+
}
154+
// After the first snapshot, before the second.
155+
expect(midSnapshotInput).toBe(10);
156+
// After drain — second snapshot has been folded in.
157+
expect(handle.getUsage().inputTokens).toBe(15);
158+
expect(handle.getUsage().costUsd).toBe(0.05);
159+
});
160+
});
161+
162+
describe("ChatHandle — dual iteration interface", () => {
163+
it("`for await` yields every event in order", async () => {
164+
const { handle } = makeHandle([
165+
{ kind: "sdk_message", sessionId: SESS, payload: "a" } as HarnessEvent,
166+
{ kind: "sdk_message", sessionId: SESS, payload: "b" } as HarnessEvent,
167+
endedEvent(),
168+
]);
169+
const kinds: string[] = [];
170+
for await (const ev of handle) kinds.push(ev.kind);
171+
expect(kinds).toEqual(["sdk_message", "sdk_message", "ca_session_ended"]);
172+
});
173+
174+
it("`await handle` drains to a ChatResult", async () => {
175+
const { handle } = makeHandle([
176+
{ kind: "sdk_message", sessionId: SESS, payload: { role: "assistant" } } as HarnessEvent,
177+
endedEvent(),
178+
]);
179+
const result = await handle;
180+
expect(result.sessionId).toBe(SESS);
181+
expect(result.messages).toHaveLength(1);
182+
expect(result.ended.reason).toBe("complete");
183+
});
184+
185+
it("result() is memoized — calling twice doesn't re-drain", async () => {
186+
// Build a generator that only yields once; if drain() re-iterated, the
187+
// second call would hang or throw. Memoization should give us the
188+
// cached promise on call #2.
189+
let yields = 0;
190+
async function* once(): AsyncIterable<HarnessEvent> {
191+
if (yields > 0) throw new Error("re-iteration would dead-lock");
192+
yields++;
193+
yield endedEvent();
194+
}
195+
const handle = new ChatHandle({
196+
sessionIdPromise: Promise.resolve(SESS),
197+
events: once(),
198+
harnessUrlPromise: Promise.resolve(HARNESS),
199+
fetchImpl: (async () => new Response("", { status: 200 })) as unknown as typeof fetch,
200+
});
201+
const a = await handle.result();
202+
const b = await handle.result();
203+
expect(a).toBe(b);
204+
});
205+
206+
it("drain() throws if the stream closes without ca_session_ended", async () => {
207+
const { handle } = makeHandle([
208+
{ kind: "sdk_message", sessionId: SESS, payload: "stranded" } as HarnessEvent,
209+
// no ca_session_ended
210+
]);
211+
await expect(handle.result()).rejects.toThrow(/ca_session_ended/);
212+
});
213+
});
214+
215+
describe("ChatHandle — permission callback", () => {
216+
it("calls onPermissionRequest with event args + POSTs the decision", async () => {
217+
const onPermissionRequest = vi.fn(async () => ({ decision: "allow" as const }));
218+
const { handle, fetchImpl } = makeHandle(
219+
[
220+
{
221+
kind: "ca_permission_request",
222+
sessionId: SESS,
223+
callId: "call_42",
224+
toolName: "Bash",
225+
input: { command: "ls" },
226+
risk: "low",
227+
} as HarnessEvent,
228+
endedEvent(),
229+
],
230+
{ onPermissionRequest },
231+
);
232+
233+
await handle.result();
234+
235+
// Hook fired exactly once with the exact event payload (minus discriminator).
236+
expect(onPermissionRequest).toHaveBeenCalledOnce();
237+
expect(onPermissionRequest).toHaveBeenCalledWith(
238+
"call_42",
239+
"Bash",
240+
{ command: "ls" },
241+
"low",
242+
);
243+
244+
// Decision was POSTed back to the harness at the right URL shape.
245+
expect(fetchImpl).toHaveBeenCalledWith(
246+
`${HARNESS}/v1/sessions/${SESS}/permission/call_42`,
247+
expect.objectContaining({
248+
method: "POST",
249+
headers: expect.objectContaining({ "Content-Type": "application/json" }),
250+
}),
251+
);
252+
});
253+
254+
it("default decision is `allow` when no onPermissionRequest is provided", async () => {
255+
const { handle, fetchImpl } = makeHandle([
256+
{
257+
kind: "ca_permission_request",
258+
sessionId: SESS,
259+
callId: "call_1",
260+
toolName: "Read",
261+
input: { path: "/etc/hostname" },
262+
} as HarnessEvent,
263+
endedEvent(),
264+
]);
265+
await handle.result();
266+
267+
// The first call should be the permission POST. Body says allow.
268+
const firstCall = fetchImpl.mock.calls[0];
269+
expect(firstCall[0]).toBe(`${HARNESS}/v1/sessions/${SESS}/permission/call_1`);
270+
const body = JSON.parse(firstCall[1].body);
271+
expect(body.decision).toBe("allow");
272+
});
273+
274+
it("honors a custom deny decision", async () => {
275+
const onPermissionRequest = vi.fn(async () => ({
276+
decision: "deny" as const,
277+
reason: "test refusal",
278+
}));
279+
const { handle, fetchImpl } = makeHandle(
280+
[
281+
{
282+
kind: "ca_permission_request",
283+
sessionId: SESS,
284+
callId: "call_9",
285+
toolName: "Bash",
286+
input: { command: "rm -rf /" },
287+
risk: "destructive",
288+
} as HarnessEvent,
289+
endedEvent(),
290+
],
291+
{ onPermissionRequest },
292+
);
293+
294+
await handle.result();
295+
296+
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
297+
expect(body.decision).toBe("deny");
298+
expect(body.reason).toBe("test refusal");
299+
});
300+
});
301+
302+
describe("ChatHandle — lifecycle", () => {
303+
it("fires onComplete after ca_session_ended (once)", async () => {
304+
const onComplete = vi.fn();
305+
const { handle } = makeHandle(
306+
[
307+
{ kind: "sdk_message", sessionId: SESS, payload: "x" } as HarnessEvent,
308+
endedEvent(),
309+
],
310+
{ onComplete },
311+
);
312+
await handle.result();
313+
expect(onComplete).toHaveBeenCalledOnce();
314+
});
315+
316+
it("does NOT fire onComplete if stream never ends", async () => {
317+
const onComplete = vi.fn();
318+
const { handle } = makeHandle(
319+
[{ kind: "sdk_message", sessionId: SESS, payload: "x" } as HarnessEvent],
320+
{ onComplete },
321+
);
322+
// Drain throws (no end), but we still verify the hook didn't fire.
323+
await expect(handle.result()).rejects.toThrow();
324+
expect(onComplete).not.toHaveBeenCalled();
325+
});
326+
327+
it("collects every sdk_message payload into result.messages, in order", async () => {
328+
const payloads = [
329+
{ type: "assistant", text: "hi" },
330+
{ type: "tool_use", name: "Bash" },
331+
{ type: "assistant", text: "done" },
332+
];
333+
const { handle } = makeHandle([
334+
...payloads.map((p) => ({ kind: "sdk_message", sessionId: SESS, payload: p }) as HarnessEvent),
335+
endedEvent(),
336+
]);
337+
const result = await handle.result();
338+
expect(result.messages).toEqual(payloads);
339+
});
340+
341+
it("cancel() POSTs /cancel for the right session", async () => {
342+
const { handle, fetchImpl } = makeHandle([endedEvent()]);
343+
await handle.cancel();
344+
expect(fetchImpl).toHaveBeenCalledWith(
345+
`${HARNESS}/v1/sessions/${SESS}/cancel`,
346+
expect.objectContaining({ method: "POST" }),
347+
);
348+
});
349+
});

0 commit comments

Comments
 (0)