alvin-bot 4.18.0 → 4.18.1

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 (71) hide show
  1. package/CHANGELOG.md +22 -2
  2. package/bin/cli.js +1 -1
  3. package/package.json +3 -1
  4. package/test/allowed-users-gate.test.ts +0 -98
  5. package/test/alvin-dispatch.test.ts +0 -220
  6. package/test/async-agent-chunk-flow.test.ts +0 -244
  7. package/test/async-agent-parser-staleness.test.ts +0 -412
  8. package/test/async-agent-parser-streamjson.test.ts +0 -273
  9. package/test/async-agent-parser.test.ts +0 -322
  10. package/test/async-agent-watcher.test.ts +0 -229
  11. package/test/background-bypass-integration.test.ts +0 -443
  12. package/test/background-bypass-stress.test.ts +0 -417
  13. package/test/background-bypass.test.ts +0 -127
  14. package/test/browser-webfetch.test.ts +0 -121
  15. package/test/claude-sdk-provider.test.ts +0 -115
  16. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  17. package/test/console-timestamps.test.ts +0 -98
  18. package/test/cron-progress-ticker.test.ts +0 -76
  19. package/test/cron-restart-resilience.test.ts +0 -191
  20. package/test/cron-run-resolver.test.ts +0 -133
  21. package/test/cron-runjobnow-throw.test.ts +0 -100
  22. package/test/debounce.test.ts +0 -60
  23. package/test/delivery-registry.test.ts +0 -71
  24. package/test/exec-guard-metachars.test.ts +0 -110
  25. package/test/file-permissions.test.ts +0 -130
  26. package/test/i18n.test.ts +0 -108
  27. package/test/list-subagents-merged.test.ts +0 -172
  28. package/test/memory-extractor.test.ts +0 -151
  29. package/test/memory-layers.test.ts +0 -169
  30. package/test/memory-sdk-injection.test.ts +0 -146
  31. package/test/memory-stress-restart.test.ts +0 -337
  32. package/test/multi-session-stress.test.ts +0 -255
  33. package/test/platform-session-key.test.ts +0 -69
  34. package/test/process-manager.test.ts +0 -186
  35. package/test/registry.test.ts +0 -201
  36. package/test/session-pending-background.test.ts +0 -59
  37. package/test/session-persistence.test.ts +0 -195
  38. package/test/slack-progress-ticker.test.ts +0 -123
  39. package/test/slack-slash-command.test.ts +0 -61
  40. package/test/slack-test-connection.test.ts +0 -176
  41. package/test/stress-scenarios.test.ts +0 -356
  42. package/test/stuck-timer.test.ts +0 -116
  43. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  44. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  45. package/test/subagent-delivery.test.ts +0 -273
  46. package/test/subagent-final-text.test.ts +0 -132
  47. package/test/subagent-stats.test.ts +0 -119
  48. package/test/subagent-toolset-allowlist.test.ts +0 -146
  49. package/test/subagents-commands.test.ts +0 -64
  50. package/test/subagents-config.test.ts +0 -114
  51. package/test/subagents-depth.test.ts +0 -58
  52. package/test/subagents-inheritance.test.ts +0 -67
  53. package/test/subagents-name-resolver.test.ts +0 -122
  54. package/test/subagents-priority-reject.test.ts +0 -88
  55. package/test/subagents-queue.test.ts +0 -127
  56. package/test/subagents-shutdown.test.ts +0 -126
  57. package/test/subagents-toolset.test.ts +0 -71
  58. package/test/sync-task-timeout.test.ts +0 -153
  59. package/test/system-prompt-background-hint.test.ts +0 -65
  60. package/test/telegram-error-filter.test.ts +0 -85
  61. package/test/telegram-workspace-command.test.ts +0 -78
  62. package/test/timing-safe-bearer.test.ts +0 -65
  63. package/test/watchdog-brake.test.ts +0 -157
  64. package/test/watcher-pending-count.test.ts +0 -228
  65. package/test/watcher-zombie-fix.test.ts +0 -252
  66. package/test/web-server-integration.test.ts +0 -189
  67. package/test/web-server-resilience.test.ts +0 -118
  68. package/test/web-server-shutdown.test.ts +0 -117
  69. package/test/whatsapp-auth-resilience.test.ts +0 -96
  70. package/test/workspaces.test.ts +0 -196
  71. package/vitest.config.ts +0 -17
@@ -1,412 +0,0 @@
1
- /**
2
- * v4.12.4 — parseOutputFileStatus staleness detection.
3
- *
4
- * Problem this fixes: when a background sub-agent is interrupted (e.g. by
5
- * v4.12.3's bypass-abort propagating through the SDK subprocess), its
6
- * outputFile is left with partial JSONL — real work, real text — but
7
- * without the `stop_reason: "end_turn"` marker the pre-v4.12.4 parser
8
- * required for "completed" state.
9
- *
10
- * Real-world evidence (2026-04-16):
11
- * - Three agents (a03ce829, af61fa6e, ac47c4a2) pending in state file
12
- * - Each outputFile has 81-131 lines of REAL work (WebSearch, tool_use,
13
- * partial reports like "Here's the summary:\n\n## Critical Bugs")
14
- * - Last event is either "[Request interrupted by user for tool use]"
15
- * or a mid-streaming assistant text that never got end_turn
16
- * - Watcher polls forever, hits 12h giveUpAt, delivers "empty output"
17
- * - User sees useless "720m timeout · 0 in / 0 out · (empty output)"
18
- * messages hours later, while the actual work is sitting on disk
19
- *
20
- * Fix behavior:
21
- * - If no end_turn is found, check mtime/size of the file
22
- * - If file hasn't been touched for `stalenessMs` (default 5 min) AND
23
- * there's usable text content in the tail, mark as "completed"
24
- * with the partial output PREFIXED by an "⚠️ interrupted, partial
25
- * output" header so the user knows it's not a clean finish
26
- * - If file IS fresh or has no text content, stay in "running" state
27
- * (normal polling continues)
28
- *
29
- * This deliberately biases toward delivering SOMETHING rather than
30
- * nothing. Worst case: an agent that's still alive but genuinely idle
31
- * for >5 min gets its partial text delivered early. Best case: dozens
32
- * of stuck interrupted agents get their real work back to the user.
33
- */
34
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
35
- import fs from "fs";
36
- import os from "os";
37
- import { resolve } from "path";
38
- import { parseOutputFileStatus } from "../src/services/async-agent-parser.js";
39
-
40
- const TMP_BASE = resolve(os.tmpdir(), `alvin-parser-stale-${process.pid}`);
41
-
42
- beforeEach(() => {
43
- fs.mkdirSync(TMP_BASE, { recursive: true });
44
- });
45
- afterEach(() => {
46
- try {
47
- fs.rmSync(TMP_BASE, { recursive: true, force: true });
48
- } catch {
49
- /* ignore */
50
- }
51
- });
52
-
53
- /**
54
- * Write a JSONL file with a mid-execution interrupted state. No end_turn,
55
- * but contains real assistant text + tool calls. Last line is the
56
- * "Request interrupted" marker.
57
- */
58
- function writeInterruptedJsonl(name: string): string {
59
- const path = resolve(TMP_BASE, name);
60
- const lines = [
61
- JSON.stringify({
62
- type: "user",
63
- isSidechain: true,
64
- agentId: "x",
65
- message: { role: "user", content: "do a report" },
66
- }),
67
- JSON.stringify({
68
- type: "assistant",
69
- isSidechain: true,
70
- agentId: "x",
71
- message: {
72
- role: "assistant",
73
- content: [{ type: "text", text: "Starting research..." }],
74
- stop_reason: "tool_use",
75
- },
76
- }),
77
- JSON.stringify({
78
- type: "assistant",
79
- isSidechain: true,
80
- agentId: "x",
81
- message: {
82
- role: "assistant",
83
- content: [
84
- {
85
- type: "text",
86
- text:
87
- "Here's what I found:\n\n## Key Findings\n- Finding A\n- Finding B\n- Finding C",
88
- },
89
- ],
90
- stop_reason: "tool_use",
91
- },
92
- }),
93
- JSON.stringify({
94
- type: "user",
95
- isSidechain: true,
96
- agentId: "x",
97
- message: {
98
- role: "user",
99
- content: [
100
- {
101
- type: "tool_result",
102
- content: "[Request interrupted by user for tool use]",
103
- },
104
- ],
105
- },
106
- }),
107
- ];
108
- fs.writeFileSync(path, lines.join("\n") + "\n", "utf-8");
109
- return path;
110
- }
111
-
112
- /** Set file mtime to N ms in the past. */
113
- function setStale(path: string, ageMs: number): void {
114
- const target = Date.now() - ageMs;
115
- fs.utimesSync(path, target / 1000, target / 1000);
116
- }
117
-
118
- describe("parseOutputFileStatus — staleness detection (v4.12.4)", () => {
119
- it("still returns 'completed' when end_turn is present (staleness is a fallback only)", async () => {
120
- const path = resolve(TMP_BASE, "complete.jsonl");
121
- fs.writeFileSync(
122
- path,
123
- JSON.stringify({
124
- type: "assistant",
125
- agentId: "x",
126
- message: {
127
- content: [{ type: "text", text: "clean end" }],
128
- stop_reason: "end_turn",
129
- },
130
- }) + "\n",
131
- "utf-8",
132
- );
133
- setStale(path, 3600_000); // 1h old
134
- const status = await parseOutputFileStatus(path, {
135
- stalenessMs: 300_000,
136
- });
137
- expect(status.state).toBe("completed");
138
- if (status.state === "completed") {
139
- expect(status.output).toContain("clean end");
140
- // No interrupted banner for clean end_turn
141
- expect(status.output).not.toMatch(/interrupt/i);
142
- }
143
- });
144
-
145
- it("returns 'running' when file is fresh and no end_turn (normal polling)", async () => {
146
- const path = writeInterruptedJsonl("fresh-interrupted.jsonl");
147
- // File is fresh (just written)
148
- const status = await parseOutputFileStatus(path, {
149
- stalenessMs: 300_000,
150
- });
151
- expect(status.state).toBe("running");
152
- });
153
-
154
- it("returns 'completed' (partial) when file is stale AND has text content", async () => {
155
- const path = writeInterruptedJsonl("stale-interrupted.jsonl");
156
- setStale(path, 600_000); // 10 min old
157
- const status = await parseOutputFileStatus(path, {
158
- stalenessMs: 300_000, // 5 min threshold
159
- });
160
- expect(status.state).toBe("completed");
161
- if (status.state === "completed") {
162
- // Should contain the real report content
163
- expect(status.output).toContain("Key Findings");
164
- expect(status.output).toContain("Finding A");
165
- // Should be prefixed with an interrupted banner so user knows
166
- // (German "unterbrochen" / "partielle" OR English "interrupted"/"partial")
167
- expect(status.output).toMatch(/interrupt|partial|unterbroch|partiell|⚠️/i);
168
- }
169
- });
170
-
171
- it("returns 'running' when file is stale but has NO text content (nothing to deliver)", async () => {
172
- // Only tool-use events, no text. Delivery would be useless.
173
- const path = resolve(TMP_BASE, "no-text.jsonl");
174
- fs.writeFileSync(
175
- path,
176
- [
177
- JSON.stringify({
178
- type: "user",
179
- agentId: "x",
180
- message: { role: "user", content: "go" },
181
- }),
182
- JSON.stringify({
183
- type: "assistant",
184
- agentId: "x",
185
- message: {
186
- content: [
187
- { type: "tool_use", name: "Bash", input: { command: "ls" } },
188
- ],
189
- stop_reason: "tool_use",
190
- },
191
- }),
192
- ].join("\n") + "\n",
193
- "utf-8",
194
- );
195
- setStale(path, 600_000);
196
- const status = await parseOutputFileStatus(path, {
197
- stalenessMs: 300_000,
198
- });
199
- expect(status.state).toBe("running");
200
- });
201
-
202
- it("default stalenessMs is applied when not provided (no crashes on legacy callers)", async () => {
203
- const path = writeInterruptedJsonl("default-cfg.jsonl");
204
- setStale(path, 24 * 3600_000); // 24h old — very stale
205
- const status = await parseOutputFileStatus(path);
206
- // Whatever the default is, 24h should definitely exceed it
207
- expect(status.state).toBe("completed");
208
- });
209
-
210
- it("stalenessMs: 0 disables the staleness fallback entirely", async () => {
211
- const path = writeInterruptedJsonl("disabled.jsonl");
212
- setStale(path, 24 * 3600_000);
213
- const status = await parseOutputFileStatus(path, { stalenessMs: 0 });
214
- // With staleness disabled, we're back to strict end_turn requirement
215
- expect(status.state).toBe("running");
216
- });
217
-
218
- it("aggregates ALL text blocks from ALL assistant turns when delivering partial", async () => {
219
- const path = resolve(TMP_BASE, "multi-turn-interrupted.jsonl");
220
- const lines = [
221
- { type: "user", agentId: "x", message: { role: "user", content: "go" } },
222
- {
223
- type: "assistant",
224
- agentId: "x",
225
- message: {
226
- content: [{ type: "text", text: "First thought." }],
227
- stop_reason: "tool_use",
228
- },
229
- },
230
- {
231
- type: "assistant",
232
- agentId: "x",
233
- message: {
234
- content: [{ type: "text", text: "Second thought." }],
235
- stop_reason: "tool_use",
236
- },
237
- },
238
- {
239
- type: "assistant",
240
- agentId: "x",
241
- message: {
242
- content: [{ type: "text", text: "Final partial report." }],
243
- stop_reason: "tool_use",
244
- },
245
- },
246
- ];
247
- fs.writeFileSync(
248
- path,
249
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
250
- "utf-8",
251
- );
252
- setStale(path, 600_000);
253
- const status = await parseOutputFileStatus(path, {
254
- stalenessMs: 300_000,
255
- });
256
- expect(status.state).toBe("completed");
257
- if (status.state === "completed") {
258
- // Should contain text from all three turns (bias toward delivering more)
259
- expect(status.output).toContain("First thought");
260
- expect(status.output).toContain("Second thought");
261
- expect(status.output).toContain("Final partial report");
262
- }
263
- });
264
-
265
- it("ignores thinking blocks in partial delivery (user doesn't want Claude's scratchpad)", async () => {
266
- const path = resolve(TMP_BASE, "thinking-filter.jsonl");
267
- const lines = [
268
- {
269
- type: "assistant",
270
- agentId: "x",
271
- message: {
272
- content: [
273
- { type: "thinking", text: "internal reasoning nobody should see" },
274
- { type: "text", text: "Actual output text." },
275
- ],
276
- stop_reason: "tool_use",
277
- },
278
- },
279
- ];
280
- fs.writeFileSync(
281
- path,
282
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
283
- "utf-8",
284
- );
285
- setStale(path, 600_000);
286
- const status = await parseOutputFileStatus(path, {
287
- stalenessMs: 300_000,
288
- });
289
- expect(status.state).toBe("completed");
290
- if (status.state === "completed") {
291
- expect(status.output).toContain("Actual output text");
292
- expect(status.output).not.toContain("internal reasoning");
293
- }
294
- });
295
-
296
- it("extracts usage tokens from the last assistant event when available", async () => {
297
- const path = resolve(TMP_BASE, "tokens-partial.jsonl");
298
- const lines = [
299
- {
300
- type: "assistant",
301
- agentId: "x",
302
- message: {
303
- content: [{ type: "text", text: "partial text" }],
304
- stop_reason: "tool_use",
305
- usage: { input_tokens: 500, output_tokens: 200 },
306
- },
307
- },
308
- ];
309
- fs.writeFileSync(
310
- path,
311
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
312
- "utf-8",
313
- );
314
- setStale(path, 600_000);
315
- const status = await parseOutputFileStatus(path, {
316
- stalenessMs: 300_000,
317
- });
318
- expect(status.state).toBe("completed");
319
- if (status.state === "completed") {
320
- expect(status.tokensUsed).toEqual({ input: 500, output: 200 });
321
- }
322
- });
323
-
324
- it("handles file that only has the interruption marker (nothing useful to deliver)", async () => {
325
- // Edge case: only interruption, no prior text
326
- const path = resolve(TMP_BASE, "only-interrupt.jsonl");
327
- const lines = [
328
- {
329
- type: "user",
330
- agentId: "x",
331
- message: {
332
- role: "user",
333
- content: [
334
- {
335
- type: "tool_result",
336
- content: "[Request interrupted by user for tool use]",
337
- },
338
- ],
339
- },
340
- },
341
- ];
342
- fs.writeFileSync(
343
- path,
344
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
345
- "utf-8",
346
- );
347
- setStale(path, 600_000);
348
- const status = await parseOutputFileStatus(path, {
349
- stalenessMs: 300_000,
350
- });
351
- // No assistant text content at all → still running (nothing useful)
352
- expect(status.state).toBe("running");
353
- });
354
-
355
- it("preserves ordering of text across turns (earlier text first, later text last)", async () => {
356
- const path = resolve(TMP_BASE, "order.jsonl");
357
- const lines = [
358
- {
359
- type: "assistant",
360
- agentId: "x",
361
- message: {
362
- content: [{ type: "text", text: "ALPHA" }],
363
- stop_reason: "tool_use",
364
- },
365
- },
366
- {
367
- type: "user",
368
- agentId: "x",
369
- message: { content: [{ type: "tool_result", content: "..." }] },
370
- },
371
- {
372
- type: "assistant",
373
- agentId: "x",
374
- message: {
375
- content: [{ type: "text", text: "BETA" }],
376
- stop_reason: "tool_use",
377
- },
378
- },
379
- {
380
- type: "user",
381
- agentId: "x",
382
- message: { content: [{ type: "tool_result", content: "..." }] },
383
- },
384
- {
385
- type: "assistant",
386
- agentId: "x",
387
- message: {
388
- content: [{ type: "text", text: "GAMMA" }],
389
- stop_reason: "tool_use",
390
- },
391
- },
392
- ];
393
- fs.writeFileSync(
394
- path,
395
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
396
- "utf-8",
397
- );
398
- setStale(path, 600_000);
399
- const status = await parseOutputFileStatus(path, {
400
- stalenessMs: 300_000,
401
- });
402
- expect(status.state).toBe("completed");
403
- if (status.state === "completed") {
404
- const alphaIdx = status.output.indexOf("ALPHA");
405
- const betaIdx = status.output.indexOf("BETA");
406
- const gammaIdx = status.output.indexOf("GAMMA");
407
- expect(alphaIdx).toBeGreaterThan(-1);
408
- expect(betaIdx).toBeGreaterThan(alphaIdx);
409
- expect(gammaIdx).toBeGreaterThan(betaIdx);
410
- }
411
- });
412
- });
@@ -1,273 +0,0 @@
1
- /**
2
- * v4.13 — parseOutputFileStatus support for `claude -p --output-format stream-json`.
3
- *
4
- * The SDK's built-in Task tool writes its sub-agent output in one JSONL
5
- * format (events with `message.stop_reason: "end_turn"`). The new v4.13
6
- * dispatch mechanism spawns `claude -p --output-format stream-json`
7
- * which writes a DIFFERENT format:
8
- *
9
- * - Assistant messages have `message.stop_reason: null` (streaming shape)
10
- * - A final `{"type":"result","subtype":"success","stop_reason":"end_turn",...}`
11
- * event marks completion explicitly
12
- * - `result.duration_ms`, `total_cost_usd`, `num_turns`, `usage`
13
- * are the authoritative completion signals
14
- *
15
- * The parser must recognize BOTH formats. v4.13 adds detection for the
16
- * result-event format while preserving backward compat with the existing
17
- * SDK-internal format (tested in the sibling test files).
18
- */
19
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
20
- import fs from "fs";
21
- import os from "os";
22
- import { resolve } from "path";
23
- import { parseOutputFileStatus } from "../src/services/async-agent-parser.js";
24
-
25
- const TMP_BASE = resolve(
26
- os.tmpdir(),
27
- `alvin-parser-streamjson-${process.pid}`,
28
- );
29
-
30
- beforeEach(() => {
31
- fs.mkdirSync(TMP_BASE, { recursive: true });
32
- });
33
- afterEach(() => {
34
- try {
35
- fs.rmSync(TMP_BASE, { recursive: true, force: true });
36
- } catch {
37
- /* ignore */
38
- }
39
- });
40
-
41
- describe("parseOutputFileStatus — stream-json format (v4.13)", () => {
42
- it("returns 'completed' when final event is type:result + subtype:success", async () => {
43
- const path = resolve(TMP_BASE, "stream-success.jsonl");
44
- const lines = [
45
- { type: "system", subtype: "init", session_id: "s1" },
46
- {
47
- type: "assistant",
48
- message: {
49
- role: "assistant",
50
- content: [{ type: "text", text: "The answer is 42." }],
51
- stop_reason: null, // streaming shape — NOT end_turn yet
52
- },
53
- session_id: "s1",
54
- },
55
- {
56
- type: "result",
57
- subtype: "success",
58
- stop_reason: "end_turn",
59
- session_id: "s1",
60
- total_cost_usd: 0.01,
61
- duration_ms: 500,
62
- usage: { input_tokens: 10, output_tokens: 5 },
63
- result: "The answer is 42.",
64
- },
65
- ];
66
- fs.writeFileSync(
67
- path,
68
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
69
- "utf-8",
70
- );
71
-
72
- const status = await parseOutputFileStatus(path);
73
- expect(status.state).toBe("completed");
74
- if (status.state === "completed") {
75
- expect(status.output).toContain("The answer is 42.");
76
- expect(status.output).not.toMatch(/interrupted|partial/i);
77
- }
78
- });
79
-
80
- it("extracts tokens from result.usage when using stream-json format", async () => {
81
- const path = resolve(TMP_BASE, "stream-tokens.jsonl");
82
- const lines = [
83
- {
84
- type: "assistant",
85
- message: {
86
- content: [{ type: "text", text: "x" }],
87
- stop_reason: null,
88
- },
89
- },
90
- {
91
- type: "result",
92
- subtype: "success",
93
- stop_reason: "end_turn",
94
- usage: { input_tokens: 1234, output_tokens: 567 },
95
- },
96
- ];
97
- fs.writeFileSync(
98
- path,
99
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
100
- "utf-8",
101
- );
102
- const status = await parseOutputFileStatus(path);
103
- expect(status.state).toBe("completed");
104
- if (status.state === "completed") {
105
- expect(status.tokensUsed).toEqual({ input: 1234, output: 567 });
106
- }
107
- });
108
-
109
- it("recognises 'failed' state when result.is_error is true", async () => {
110
- const path = resolve(TMP_BASE, "stream-failed.jsonl");
111
- const lines = [
112
- {
113
- type: "assistant",
114
- message: {
115
- content: [{ type: "text", text: "I tried..." }],
116
- stop_reason: null,
117
- },
118
- },
119
- {
120
- type: "result",
121
- subtype: "error_max_turns",
122
- is_error: true,
123
- stop_reason: "max_turns",
124
- },
125
- ];
126
- fs.writeFileSync(
127
- path,
128
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
129
- "utf-8",
130
- );
131
- const status = await parseOutputFileStatus(path);
132
- // With an is_error result + text content, we still deliver the text
133
- // as completed (better to give the user SOMETHING than nothing).
134
- // The delivery layer can annotate differently if it chooses.
135
- expect(status.state).toBe("completed");
136
- if (status.state === "completed") {
137
- expect(status.output).toContain("I tried...");
138
- }
139
- });
140
-
141
- it("returns 'running' when stream-json events are present but no result yet", async () => {
142
- const path = resolve(TMP_BASE, "stream-running.jsonl");
143
- const lines = [
144
- { type: "system", subtype: "init", session_id: "s1" },
145
- {
146
- type: "assistant",
147
- message: {
148
- content: [{ type: "text", text: "Thinking..." }],
149
- stop_reason: null,
150
- },
151
- },
152
- {
153
- type: "assistant",
154
- message: {
155
- content: [{ type: "tool_use", name: "Bash", input: {} }],
156
- stop_reason: null,
157
- },
158
- },
159
- ];
160
- fs.writeFileSync(
161
- path,
162
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
163
- "utf-8",
164
- );
165
- const status = await parseOutputFileStatus(path);
166
- expect(status.state).toBe("running");
167
- });
168
-
169
- it("aggregates text from ALL assistant messages when result arrives", async () => {
170
- const path = resolve(TMP_BASE, "stream-multi-text.jsonl");
171
- const lines = [
172
- {
173
- type: "assistant",
174
- message: {
175
- content: [{ type: "text", text: "First thought." }],
176
- stop_reason: null,
177
- },
178
- },
179
- {
180
- type: "user",
181
- message: { content: [{ type: "tool_result", content: "ok" }] },
182
- },
183
- {
184
- type: "assistant",
185
- message: {
186
- content: [{ type: "text", text: "Continuing..." }],
187
- stop_reason: null,
188
- },
189
- },
190
- {
191
- type: "user",
192
- message: { content: [{ type: "tool_result", content: "ok" }] },
193
- },
194
- {
195
- type: "assistant",
196
- message: {
197
- content: [{ type: "text", text: "Final answer." }],
198
- stop_reason: null,
199
- },
200
- },
201
- { type: "result", subtype: "success", stop_reason: "end_turn" },
202
- ];
203
- fs.writeFileSync(
204
- path,
205
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
206
- "utf-8",
207
- );
208
- const status = await parseOutputFileStatus(path);
209
- expect(status.state).toBe("completed");
210
- if (status.state === "completed") {
211
- // All three text blocks must be present
212
- expect(status.output).toContain("First thought");
213
- expect(status.output).toContain("Continuing");
214
- expect(status.output).toContain("Final answer");
215
- }
216
- });
217
-
218
- it("prefers result.result field as authoritative output when available", async () => {
219
- // The stream-json's result event has a `result` field with the
220
- // already-concatenated final answer. Use it directly when present
221
- // (more accurate than re-aggregating from streaming chunks).
222
- const path = resolve(TMP_BASE, "stream-result-field.jsonl");
223
- const lines = [
224
- {
225
- type: "assistant",
226
- message: {
227
- content: [{ type: "text", text: "Intermediate chunk" }],
228
- stop_reason: null,
229
- },
230
- },
231
- {
232
- type: "result",
233
- subtype: "success",
234
- stop_reason: "end_turn",
235
- result: "FINAL AUTHORITATIVE ANSWER",
236
- },
237
- ];
238
- fs.writeFileSync(
239
- path,
240
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
241
- "utf-8",
242
- );
243
- const status = await parseOutputFileStatus(path);
244
- expect(status.state).toBe("completed");
245
- if (status.state === "completed") {
246
- expect(status.output).toContain("FINAL AUTHORITATIVE ANSWER");
247
- }
248
- });
249
-
250
- it("handles result event with only partial fields (defensive)", async () => {
251
- const path = resolve(TMP_BASE, "stream-result-minimal.jsonl");
252
- const lines = [
253
- {
254
- type: "assistant",
255
- message: {
256
- content: [{ type: "text", text: "Some output" }],
257
- stop_reason: null,
258
- },
259
- },
260
- { type: "result" }, // no subtype, no result field, no usage
261
- ];
262
- fs.writeFileSync(
263
- path,
264
- lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
265
- "utf-8",
266
- );
267
- const status = await parseOutputFileStatus(path);
268
- expect(status.state).toBe("completed");
269
- if (status.state === "completed") {
270
- expect(status.output).toContain("Some output");
271
- }
272
- });
273
- });