@openparachute/agent 0.2.2 → 0.2.3-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/transports/vault.ts +40 -22
  4. package/web/ui/dist/assets/index-5KEwEhfi.js +60 -0
  5. package/web/ui/dist/index.html +1 -1
  6. package/src/_parked/interactive-spawn.test.ts +0 -324
  7. package/src/_parked/interactive-spawn.ts +0 -701
  8. package/src/agent-defs.test.ts +0 -1504
  9. package/src/agent-mcp-config.test.ts +0 -115
  10. package/src/agents.test.ts +0 -360
  11. package/src/auth.test.ts +0 -46
  12. package/src/backends/attached-queue.test.ts +0 -376
  13. package/src/backends/programmatic.test.ts +0 -1715
  14. package/src/backends/registry.test.ts +0 -1494
  15. package/src/backends/stream-json.test.ts +0 -570
  16. package/src/channel-backend-wiring.test.ts +0 -237
  17. package/src/credentials.test.ts +0 -274
  18. package/src/cron.test.ts +0 -342
  19. package/src/daemon-agent-def-api.test.ts +0 -166
  20. package/src/daemon-agent-defs-api.test.ts +0 -953
  21. package/src/daemon-agent-env-api.test.ts +0 -338
  22. package/src/daemon-attached-queue-store.test.ts +0 -65
  23. package/src/daemon-config-api.test.ts +0 -962
  24. package/src/daemon-jobs-api.test.ts +0 -271
  25. package/src/daemon-vault-chat.test.ts +0 -250
  26. package/src/daemon.test.ts +0 -746
  27. package/src/def-vaults.test.ts +0 -136
  28. package/src/delivery-state.test.ts +0 -110
  29. package/src/effective-env.test.ts +0 -114
  30. package/src/grants.test.ts +0 -638
  31. package/src/hub-jwt.test.ts +0 -161
  32. package/src/jobs.test.ts +0 -245
  33. package/src/mcp-http.test.ts +0 -265
  34. package/src/mint-token.test.ts +0 -152
  35. package/src/module-manifest.test.ts +0 -158
  36. package/src/programmatic-wiring.test.ts +0 -838
  37. package/src/registry.test.ts +0 -227
  38. package/src/resolve-port.test.ts +0 -64
  39. package/src/routing.test.ts +0 -184
  40. package/src/runner.test.ts +0 -506
  41. package/src/sandbox/config.test.ts +0 -150
  42. package/src/sandbox/egress.test.ts +0 -113
  43. package/src/sandbox/live-seatbelt.test.ts +0 -277
  44. package/src/sandbox/mounts.test.ts +0 -154
  45. package/src/sandbox/sandbox.test.ts +0 -168
  46. package/src/services-manifest.test.ts +0 -106
  47. package/src/spa-serve.test.ts +0 -116
  48. package/src/spawn-agent-cli.test.ts +0 -172
  49. package/src/spawn-agent.test.ts +0 -1218
  50. package/src/spawn-deps.test.ts +0 -54
  51. package/src/terminal-assets.test.ts +0 -50
  52. package/src/terminal.test.ts +0 -530
  53. package/src/transports/http-ui.test.ts +0 -455
  54. package/src/transports/telegram.test.ts +0 -174
  55. package/src/transports/vault.test.ts +0 -2011
  56. package/src/ui-kit.test.ts +0 -178
  57. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  58. package/web/ui/tsconfig.json +0 -21
@@ -1,570 +0,0 @@
1
- /**
2
- * parseStreamJson tests — the `claude -p --output-format stream-json --verbose`
3
- * NDJSON parser. Built to the VERIFIED event shapes (claude 2.1.179).
4
- *
5
- * Covered:
6
- * - a success turn: session_id from init, reply from result, success/subtype, usage, cost;
7
- * - apiKeySource "none" surfaced (the subscription-auth signal);
8
- * - an error turn (is_error / non-success subtype) → success=false, error message;
9
- * - session_id captured from the FIRST event (init precedes result);
10
- * - robustness: interleaved hook / rate_limit_event lines + a trailing PARTIAL line;
11
- * - no result event at all → success undefined (a truncated/crashed turn);
12
- * - blank/garbage input → an empty parse, never a throw.
13
- */
14
- import { describe, test, expect } from "bun:test";
15
- import {
16
- parseStreamJson,
17
- parseStreamJsonStream,
18
- MAX_TOOL_INPUT_CHARS,
19
- MAX_TOOL_RESULT_CHARS,
20
- type InterimTurnEvent,
21
- } from "./stream-json.ts";
22
-
23
- /** Join NDJSON event objects into the line-delimited blob claude emits. */
24
- function ndjson(...events: unknown[]): string {
25
- return events.map((e) => JSON.stringify(e)).join("\n") + "\n";
26
- }
27
-
28
- /** A ReadableStream<Uint8Array> that emits the given byte chunks in order (then closes). */
29
- function streamOf(...chunks: string[]): ReadableStream<Uint8Array> {
30
- const enc = new TextEncoder();
31
- return new ReadableStream<Uint8Array>({
32
- start(controller) {
33
- for (const c of chunks) controller.enqueue(enc.encode(c));
34
- controller.close();
35
- },
36
- });
37
- }
38
-
39
- /** Collect the interim events parseStreamJsonStream emits while parsing `chunks`. */
40
- async function collectInterim(
41
- ...chunks: string[]
42
- ): Promise<{ events: InterimTurnEvent[]; turn: Awaited<ReturnType<typeof parseStreamJsonStream>> }> {
43
- const events: InterimTurnEvent[] = [];
44
- const turn = await parseStreamJsonStream(streamOf(...chunks), (e) => events.push(e));
45
- return { events, turn };
46
- }
47
-
48
- describe("parseStreamJson — success turn", () => {
49
- test("captures session_id (from init), reply (from result), subtype, usage, cost", () => {
50
- const blob = ndjson(
51
- { type: "system", subtype: "init", session_id: "sess-123", apiKeySource: "none", mcp_servers: [] },
52
- { type: "assistant", message: { content: [{ type: "text", text: "hi there" }] }, session_id: "sess-123" },
53
- {
54
- type: "result",
55
- subtype: "success",
56
- is_error: false,
57
- result: "Here is the final reply.",
58
- session_id: "sess-123",
59
- usage: { input_tokens: 42, output_tokens: 7 },
60
- total_cost_usd: 0.0012,
61
- },
62
- );
63
- const t = parseStreamJson(blob);
64
- expect(t.sessionId).toBe("sess-123");
65
- expect(t.reply).toBe("Here is the final reply.");
66
- expect(t.success).toBe(true);
67
- expect(t.subtype).toBe("success");
68
- expect(t.isError).toBe(false);
69
- expect(t.usage).toEqual({ input_tokens: 42, output_tokens: 7 });
70
- expect(t.totalCostUsd).toBe(0.0012);
71
- });
72
-
73
- test("surfaces apiKeySource 'none' — the subscription-auth signal (design §1)", () => {
74
- const blob = ndjson(
75
- { type: "system", subtype: "init", session_id: "s", apiKeySource: "none" },
76
- { type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s" },
77
- );
78
- expect(parseStreamJson(blob).apiKeySource).toBe("none");
79
- });
80
- });
81
-
82
- describe("parseStreamJson — error turn", () => {
83
- test("is_error true → success=false, error message captured from result", () => {
84
- const blob = ndjson(
85
- { type: "system", subtype: "init", session_id: "sess-err", apiKeySource: "none" },
86
- {
87
- type: "result",
88
- subtype: "error_during_execution",
89
- is_error: true,
90
- result: "something went wrong",
91
- session_id: "sess-err",
92
- },
93
- );
94
- const t = parseStreamJson(blob);
95
- expect(t.success).toBe(false);
96
- expect(t.isError).toBe(true);
97
- expect(t.subtype).toBe("error_during_execution");
98
- expect(t.errorMessage).toBe("something went wrong");
99
- // The session id is still captured (a turn can fail AFTER establishing a session).
100
- expect(t.sessionId).toBe("sess-err");
101
- });
102
-
103
- test("a non-success subtype with is_error false is STILL not success", () => {
104
- const blob = ndjson(
105
- { type: "system", subtype: "init", session_id: "s" },
106
- { type: "result", subtype: "error_max_turns", is_error: false, result: "partial", session_id: "s" },
107
- );
108
- const t = parseStreamJson(blob);
109
- expect(t.success).toBe(false);
110
- expect(t.subtype).toBe("error_max_turns");
111
- });
112
- });
113
-
114
- describe("parseStreamJson — session_id capture", () => {
115
- test("captured from the FIRST event that carries one (init precedes result)", () => {
116
- const blob = ndjson(
117
- { type: "system", subtype: "init", session_id: "first-id" },
118
- { type: "result", subtype: "success", is_error: false, result: "ok", session_id: "first-id" },
119
- );
120
- expect(parseStreamJson(blob).sessionId).toBe("first-id");
121
- });
122
-
123
- test("falls through to the result event's session_id when there's no init", () => {
124
- const blob = ndjson({ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "from-result" });
125
- expect(parseStreamJson(blob).sessionId).toBe("from-result");
126
- });
127
- });
128
-
129
- describe("parseStreamJson — robustness", () => {
130
- test("interleaved hook / rate_limit_event lines + a trailing PARTIAL line still parse the result", () => {
131
- const blob =
132
- // a non-JSON hook line (plain text from a user hook)
133
- "running PreToolUse hook...\n" +
134
- ndjson({ type: "system", subtype: "init", session_id: "sess-robust", apiKeySource: "none" }).trimEnd() +
135
- "\n" +
136
- // a rate_limit_event interleaved (the subscription five_hour pool signal)
137
- ndjson({ type: "system", subtype: "rate_limit_event", rate_limit: { five_hour: { overageStatus: "rejected" } } }).trimEnd() +
138
- "\n" +
139
- ndjson({ type: "assistant", message: { content: [{ type: "text", text: "thinking" }] }, session_id: "sess-robust" }).trimEnd() +
140
- "\n" +
141
- JSON.stringify({
142
- type: "result",
143
- subtype: "success",
144
- is_error: false,
145
- result: "robust reply",
146
- session_id: "sess-robust",
147
- usage: { input_tokens: 1, output_tokens: 2 },
148
- }) +
149
- "\n" +
150
- // a TRAILING PARTIAL line — a JSON object cut off mid-stream (no closing brace)
151
- '{"type":"system","subtype":"in';
152
- const t = parseStreamJson(blob);
153
- expect(t.sessionId).toBe("sess-robust");
154
- expect(t.reply).toBe("robust reply");
155
- expect(t.success).toBe(true);
156
- expect(t.apiKeySource).toBe("none");
157
- });
158
-
159
- test("blank lines + a leading non-{ line are skipped", () => {
160
- const blob =
161
- "\n\nsome banner text\n" +
162
- JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s" }) +
163
- "\n\n";
164
- const t = parseStreamJson(blob);
165
- expect(t.reply).toBe("ok");
166
- expect(t.success).toBe(true);
167
- });
168
-
169
- test("no result event at all → success is undefined (a truncated/crashed turn)", () => {
170
- const blob = ndjson(
171
- { type: "system", subtype: "init", session_id: "sess-trunc", apiKeySource: "none" },
172
- { type: "assistant", message: { content: [{ type: "text", text: "started…" }] }, session_id: "sess-trunc" },
173
- );
174
- const t = parseStreamJson(blob);
175
- expect(t.success).toBeUndefined();
176
- expect(t.reply).toBeUndefined();
177
- expect(t.sessionId).toBe("sess-trunc"); // id still available for a resume
178
- });
179
-
180
- test("fully blank / garbage input → an empty parse, never a throw", () => {
181
- expect(parseStreamJson("")).toEqual({});
182
- expect(parseStreamJson(" \n \n")).toEqual({});
183
- expect(parseStreamJson("not json at all\nmore noise")).toEqual({});
184
- });
185
- });
186
-
187
- describe("parseStreamJsonStream — interim events + final turn", () => {
188
- test("emits init (session) + text chunks + tool_use, and returns the same final turn", async () => {
189
- const blob = ndjson(
190
- { type: "system", subtype: "init", session_id: "sess-1", apiKeySource: "none", mcp_servers: [] },
191
- { type: "assistant", message: { content: [{ type: "text", text: "let me check" }] }, session_id: "sess-1" },
192
- {
193
- type: "assistant",
194
- message: { content: [{ type: "tool_use", name: "Read", input: {} }] },
195
- session_id: "sess-1",
196
- },
197
- { type: "assistant", message: { content: [{ type: "text", text: " — done." }] }, session_id: "sess-1" },
198
- {
199
- type: "result",
200
- subtype: "success",
201
- is_error: false,
202
- result: "let me check — done.",
203
- session_id: "sess-1",
204
- usage: { input_tokens: 9, output_tokens: 4 },
205
- total_cost_usd: 0.0009,
206
- },
207
- );
208
- const { events, turn } = await collectInterim(blob);
209
-
210
- expect(events).toEqual([
211
- { kind: "init", sessionId: "sess-1" },
212
- { kind: "text", text: "let me check" },
213
- // empty input `{}` still stringifies to "{}" and rides as the input field
214
- { kind: "tool", tool: "Read", input: "{}" },
215
- { kind: "text", text: " — done." },
216
- ]);
217
- // The FINAL turn is exactly what the blob parser would compute — durable path intact.
218
- expect(turn).toEqual(parseStreamJson(blob));
219
- expect(turn.reply).toBe("let me check — done.");
220
- expect(turn.success).toBe(true);
221
- expect(turn.sessionId).toBe("sess-1");
222
- });
223
-
224
- test("a SINGLE assistant event with multiple blocks emits each in order", async () => {
225
- const blob = ndjson(
226
- { type: "system", subtype: "init", session_id: "s" },
227
- {
228
- type: "assistant",
229
- message: {
230
- content: [
231
- { type: "text", text: "first" },
232
- { type: "tool_use", name: "Bash" },
233
- { type: "text", text: "second" },
234
- ],
235
- },
236
- session_id: "s",
237
- },
238
- { type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s" },
239
- );
240
- const { events } = await collectInterim(blob);
241
- expect(events).toEqual([
242
- { kind: "init", sessionId: "s" },
243
- { kind: "text", text: "first" },
244
- { kind: "tool", tool: "Bash" },
245
- { kind: "text", text: "second" },
246
- ]);
247
- });
248
-
249
- test("chunk boundaries SPLIT mid-line still parse + emit correctly", async () => {
250
- const blob = ndjson(
251
- { type: "system", subtype: "init", session_id: "split-1" },
252
- { type: "assistant", message: { content: [{ type: "text", text: "streamed" }] }, session_id: "split-1" },
253
- { type: "result", subtype: "success", is_error: false, result: "streamed", session_id: "split-1" },
254
- );
255
- // Split the blob at an arbitrary mid-line byte offset into two chunks.
256
- const cut = Math.floor(blob.length / 2);
257
- const { events, turn } = await collectInterim(blob.slice(0, cut), blob.slice(cut));
258
- expect(events).toEqual([
259
- { kind: "init", sessionId: "split-1" },
260
- { kind: "text", text: "streamed" },
261
- ]);
262
- expect(turn).toEqual(parseStreamJson(blob));
263
- });
264
-
265
- test("a final result line with NO trailing newline is still folded (pipe close)", async () => {
266
- // The result line is emitted WITHOUT a terminating \n (the pipe closed) — it must
267
- // still be parsed (folded once at end-of-stream).
268
- const init = JSON.stringify({ type: "system", subtype: "init", session_id: "no-nl" }) + "\n";
269
- const result = JSON.stringify({
270
- type: "result",
271
- subtype: "success",
272
- is_error: false,
273
- result: "tail reply",
274
- session_id: "no-nl",
275
- });
276
- const { turn } = await collectInterim(init, result);
277
- expect(turn.reply).toBe("tail reply");
278
- expect(turn.success).toBe(true);
279
- });
280
-
281
- test("interim sink is NEVER called for the result event (final-only), and tolerates noise", async () => {
282
- const blob =
283
- "PreToolUse hook running...\n" +
284
- ndjson({ type: "system", subtype: "init", session_id: "noise" }).trimEnd() +
285
- "\n" +
286
- ndjson({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] }, session_id: "noise" }).trimEnd() +
287
- "\n" +
288
- JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "hi", session_id: "noise" }) +
289
- "\n";
290
- const { events, turn } = await collectInterim(blob);
291
- // init + the one text chunk only — the result event produces no interim event.
292
- expect(events).toEqual([
293
- { kind: "init", sessionId: "noise" },
294
- { kind: "text", text: "hi" },
295
- ]);
296
- expect(turn.reply).toBe("hi");
297
- });
298
-
299
- test("a null stream yields an empty turn with no events (no stdout)", async () => {
300
- const events: InterimTurnEvent[] = [];
301
- const turn = await parseStreamJsonStream(null, (e) => events.push(e));
302
- expect(turn).toEqual({});
303
- expect(events).toHaveLength(0);
304
- });
305
-
306
- test("an error turn streams nothing-but-init then the final turn carries the failure", async () => {
307
- const blob = ndjson(
308
- { type: "system", subtype: "init", session_id: "err-1" },
309
- { type: "result", subtype: "error_during_execution", is_error: true, result: "kaboom", session_id: "err-1" },
310
- );
311
- const { events, turn } = await collectInterim(blob);
312
- expect(events).toEqual([{ kind: "init", sessionId: "err-1" }]);
313
- expect(turn.success).toBe(false);
314
- expect(turn.errorMessage).toBe("kaboom");
315
- expect(turn.sessionId).toBe("err-1");
316
- });
317
- });
318
-
319
- describe("parseStreamJsonStream — enriched tool events (inputs + result previews)", () => {
320
- test("a tool_use carries its input JSON-stringified on the tool event", async () => {
321
- const blob = ndjson(
322
- { type: "system", subtype: "init", session_id: "tin-1" },
323
- {
324
- type: "assistant",
325
- message: {
326
- content: [{ type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "/etc/hosts" } }],
327
- },
328
- session_id: "tin-1",
329
- },
330
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-1" },
331
- );
332
- const { events } = await collectInterim(blob);
333
- expect(events).toEqual([
334
- { kind: "init", sessionId: "tin-1" },
335
- { kind: "tool", tool: "Read", input: JSON.stringify({ file_path: "/etc/hosts" }) },
336
- ]);
337
- });
338
-
339
- test("an OVERSIZE tool input is truncated with the … marker", async () => {
340
- const bigCmd = "x".repeat(MAX_TOOL_INPUT_CHARS + 500);
341
- const blob = ndjson(
342
- { type: "system", subtype: "init", session_id: "tin-big" },
343
- {
344
- type: "assistant",
345
- message: { content: [{ type: "tool_use", id: "tu_b", name: "Bash", input: { command: bigCmd } }] },
346
- session_id: "tin-big",
347
- },
348
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-big" },
349
- );
350
- const { events } = await collectInterim(blob);
351
- const toolEvent = events.find((e) => e.kind === "tool");
352
- expect(toolEvent).toBeDefined();
353
- const input = (toolEvent as { input?: string }).input!;
354
- // exactly MAX chars + the 1-char marker, and it ends with the marker
355
- expect(input.length).toBe(MAX_TOOL_INPUT_CHARS + 1);
356
- expect(input.endsWith("…")).toBe(true);
357
- });
358
-
359
- test("an input at EXACTLY the cap is NOT truncated (no marker)", async () => {
360
- // A JSON string whose serialized form is exactly MAX_TOOL_INPUT_CHARS chars: the
361
- // `> max` (strictly-greater) guard must leave it untouched, no `…`.
362
- const padTo = MAX_TOOL_INPUT_CHARS - JSON.stringify({ command: "" }).length;
363
- const exactCmd = "z".repeat(padTo);
364
- const inputObj = { command: exactCmd };
365
- const expected = JSON.stringify(inputObj);
366
- expect(expected.length).toBe(MAX_TOOL_INPUT_CHARS); // sanity: we built it right
367
- const blob = ndjson(
368
- { type: "system", subtype: "init", session_id: "tin-exact" },
369
- {
370
- type: "assistant",
371
- message: { content: [{ type: "tool_use", id: "tu_x", name: "Bash", input: inputObj }] },
372
- session_id: "tin-exact",
373
- },
374
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-exact" },
375
- );
376
- const { events } = await collectInterim(blob);
377
- expect(events).toContainEqual({ kind: "tool", tool: "Bash", input: expected });
378
- expect((events.find((e) => e.kind === "tool") as { input?: string }).input!.endsWith("…")).toBe(false);
379
- });
380
-
381
- test("the id→name map accumulates MULTIPLE tool_use ids before their results arrive", async () => {
382
- // Two tool_use blocks fire, THEN two tool_results — each result must be labeled
383
- // with the right tool via the per-parse id→name map (no cross-talk, map survives).
384
- const blob = ndjson(
385
- { type: "system", subtype: "init", session_id: "multi" },
386
- {
387
- type: "assistant",
388
- message: {
389
- content: [
390
- { type: "tool_use", id: "tu_a", name: "Read", input: { file_path: "/a" } },
391
- { type: "tool_use", id: "tu_b", name: "Bash", input: { command: "ls" } },
392
- ],
393
- },
394
- session_id: "multi",
395
- },
396
- {
397
- type: "user",
398
- message: {
399
- content: [
400
- { type: "tool_result", tool_use_id: "tu_b", is_error: false, content: "bash out" },
401
- { type: "tool_result", tool_use_id: "tu_a", is_error: false, content: "read out" },
402
- ],
403
- },
404
- session_id: "multi",
405
- },
406
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "multi" },
407
- );
408
- const { events } = await collectInterim(blob);
409
- // tu_b → Bash, tu_a → Read (results arrived in reverse order; labels still correct)
410
- expect(events).toContainEqual({ kind: "tool_result", tool: "Bash", ok: true, preview: "bash out" });
411
- expect(events).toContainEqual({ kind: "tool_result", tool: "Read", ok: true, preview: "read out" });
412
- });
413
-
414
- test("a tool_use with NO input omits the input field (back-compat shape)", async () => {
415
- const blob = ndjson(
416
- { type: "system", subtype: "init", session_id: "tin-none" },
417
- {
418
- type: "assistant",
419
- message: { content: [{ type: "tool_use", id: "tu_n", name: "Glob" }] },
420
- session_id: "tin-none",
421
- },
422
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-none" },
423
- );
424
- const { events } = await collectInterim(blob);
425
- expect(events).toEqual([
426
- { kind: "init", sessionId: "tin-none" },
427
- { kind: "tool", tool: "Glob" },
428
- ]);
429
- });
430
-
431
- test("a tool_result → {kind:'tool_result', tool, ok, preview} labeled via the tool_use_id map", async () => {
432
- const blob = ndjson(
433
- { type: "system", subtype: "init", session_id: "tr-1" },
434
- {
435
- type: "assistant",
436
- message: { content: [{ type: "tool_use", id: "tu_42", name: "Read", input: { file_path: "/a" } }] },
437
- session_id: "tr-1",
438
- },
439
- {
440
- type: "user",
441
- message: {
442
- content: [{ type: "tool_result", tool_use_id: "tu_42", is_error: false, content: "file contents here" }],
443
- },
444
- session_id: "tr-1",
445
- },
446
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-1" },
447
- );
448
- const { events } = await collectInterim(blob);
449
- expect(events).toContainEqual({
450
- kind: "tool_result",
451
- tool: "Read", // labeled via the tool_use_id → name map
452
- ok: true,
453
- preview: "file contents here",
454
- });
455
- });
456
-
457
- test("a tool_result with array content parts joins the text parts into the preview", async () => {
458
- const blob = ndjson(
459
- { type: "system", subtype: "init", session_id: "tr-arr" },
460
- {
461
- type: "assistant",
462
- message: { content: [{ type: "tool_use", id: "tu_arr", name: "Bash", input: { command: "ls" } }] },
463
- session_id: "tr-arr",
464
- },
465
- {
466
- type: "user",
467
- message: {
468
- content: [
469
- {
470
- type: "tool_result",
471
- tool_use_id: "tu_arr",
472
- is_error: false,
473
- content: [
474
- { type: "text", text: "line1\n" },
475
- { type: "text", text: "line2" },
476
- ],
477
- },
478
- ],
479
- },
480
- session_id: "tr-arr",
481
- },
482
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-arr" },
483
- );
484
- const { events } = await collectInterim(blob);
485
- expect(events).toContainEqual({ kind: "tool_result", tool: "Bash", ok: true, preview: "line1\nline2" });
486
- });
487
-
488
- test("an OVERSIZE tool_result preview is truncated with the … marker", async () => {
489
- const bigOut = "y".repeat(MAX_TOOL_RESULT_CHARS + 500);
490
- const blob = ndjson(
491
- { type: "system", subtype: "init", session_id: "tr-big" },
492
- {
493
- type: "assistant",
494
- message: { content: [{ type: "tool_use", id: "tu_big", name: "Bash", input: {} }] },
495
- session_id: "tr-big",
496
- },
497
- {
498
- type: "user",
499
- message: { content: [{ type: "tool_result", tool_use_id: "tu_big", is_error: false, content: bigOut }] },
500
- session_id: "tr-big",
501
- },
502
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-big" },
503
- );
504
- const { events } = await collectInterim(blob);
505
- const resultEvent = events.find((e) => e.kind === "tool_result");
506
- expect(resultEvent).toBeDefined();
507
- const preview = (resultEvent as { preview?: string }).preview!;
508
- expect(preview.length).toBe(MAX_TOOL_RESULT_CHARS + 1);
509
- expect(preview.endsWith("…")).toBe(true);
510
- });
511
-
512
- test("is_error:true → ok:false", async () => {
513
- const blob = ndjson(
514
- { type: "system", subtype: "init", session_id: "tr-err" },
515
- {
516
- type: "assistant",
517
- message: { content: [{ type: "tool_use", id: "tu_e", name: "Bash", input: { command: "false" } }] },
518
- session_id: "tr-err",
519
- },
520
- {
521
- type: "user",
522
- message: {
523
- content: [{ type: "tool_result", tool_use_id: "tu_e", is_error: true, content: "command failed" }],
524
- },
525
- session_id: "tr-err",
526
- },
527
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-err" },
528
- );
529
- const { events } = await collectInterim(blob);
530
- expect(events).toContainEqual({ kind: "tool_result", tool: "Bash", ok: false, preview: "command failed" });
531
- });
532
-
533
- test("a tool_result whose tool_use_id is unknown still emits, just without the tool label", async () => {
534
- const blob = ndjson(
535
- { type: "system", subtype: "init", session_id: "tr-orphan" },
536
- {
537
- type: "user",
538
- message: {
539
- content: [{ type: "tool_result", tool_use_id: "tu_unknown", is_error: false, content: "orphan result" }],
540
- },
541
- session_id: "tr-orphan",
542
- },
543
- { type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-orphan" },
544
- );
545
- const { events } = await collectInterim(blob);
546
- // no `tool` key (unknown id), but ok + preview present
547
- expect(events).toContainEqual({ kind: "tool_result", ok: true, preview: "orphan result" });
548
- });
549
-
550
- test("the blob parser (no sink) ignores tool inputs + results entirely — final turn unchanged", () => {
551
- const blob = ndjson(
552
- { type: "system", subtype: "init", session_id: "blob-tr" },
553
- {
554
- type: "assistant",
555
- message: { content: [{ type: "tool_use", id: "tu_x", name: "Read", input: { file_path: "/x" } }] },
556
- session_id: "blob-tr",
557
- },
558
- {
559
- type: "user",
560
- message: { content: [{ type: "tool_result", tool_use_id: "tu_x", is_error: false, content: "stuff" }] },
561
- session_id: "blob-tr",
562
- },
563
- { type: "result", subtype: "success", is_error: false, result: "final", session_id: "blob-tr" },
564
- );
565
- const t = parseStreamJson(blob);
566
- expect(t.reply).toBe("final");
567
- expect(t.success).toBe(true);
568
- expect(t.sessionId).toBe("blob-tr");
569
- });
570
- });