@onmars/lunar-agent-claude 0.1.0

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.
@@ -0,0 +1,1383 @@
1
+ /**
2
+ * # ClaudeAgent Adapter — Functional Specification
3
+ *
4
+ * Tests the Claude Code CLI spawn adapter in isolation (no real subprocesses).
5
+ *
6
+ * ## buildArgs() — CLI argument construction
7
+ * Constructs the full argv array for Bun.spawn from AgentInput + options.
8
+ * Always includes: -p <prompt>, --output-format stream-json, --verbose,
9
+ * --permission-mode bypassPermissions.
10
+ * Conditionally: --resume, --append-system-prompt, --model, --max-turns.
11
+ *
12
+ * ## buildEnv() — Environment variable filtering
13
+ * Auth modes: 'stored' (clear API keys), 'api-key' (pass ANTHROPIC_API_KEY),
14
+ * 'oauth-token' (pass ANTHROPIC_AUTH_TOKEN).
15
+ * With security config: delegates to buildSafeEnv (allowlist).
16
+ * Without: legacy blocklist approach.
17
+ * context1m=false sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1.
18
+ *
19
+ * ## processMessage() — JSONL event parsing
20
+ * Maps Claude CLI stream-json types to AgentEvent:
21
+ * - 'assistant' with text blocks → { type: 'text' }
22
+ * - 'assistant' with thinking blocks → { type: 'thinking' }
23
+ * - 'tool_use' → { type: 'tool_use' }
24
+ * - 'tool_result' → { type: 'tool_result' }
25
+ * - 'error' → { type: 'error' }
26
+ * - 'result' → no-op (final summary, text captured from assistant blocks)
27
+ *
28
+ * ## resolveModelAlias() — Model name resolution
29
+ * Maps short aliases to full Claude model IDs.
30
+ * 'opus' → 'claude-opus-4-6', 'sonnet' → 'claude-sonnet-4-6', etc.
31
+ * Unknown names pass through unchanged.
32
+ */
33
+ import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
34
+ import { ClaudeAgent, type ClaudeAgentOptions } from '../adapter'
35
+
36
+ // ─── Helpers ────────────────────────────────────────────────────────
37
+
38
+ /** Create an agent and extract private methods via prototype access */
39
+ function createAgent(overrides: Partial<ClaudeAgentOptions> = {}): ClaudeAgent {
40
+ return new ClaudeAgent({
41
+ cwd: '/tmp/test-workspace',
42
+ ...overrides,
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Access private buildArgs via casting.
48
+ * Tests the arg construction logic without spawning processes.
49
+ */
50
+ function callBuildArgs(
51
+ agent: ClaudeAgent,
52
+ input: {
53
+ prompt: string
54
+ sessionId?: string
55
+ systemPrompt?: string
56
+ model?: string
57
+ context1m?: boolean
58
+ },
59
+ ): string[] {
60
+ return (agent as any).buildArgs(input)
61
+ }
62
+
63
+ /**
64
+ * Access private buildEnv via casting.
65
+ */
66
+ function callBuildEnv(agent: ClaudeAgent): Record<string, string> {
67
+ return (agent as any).buildEnv()
68
+ }
69
+
70
+ /**
71
+ * Access private processMessage generator via casting.
72
+ */
73
+ function callProcessMessage(agent: ClaudeAgent, msg: any): Array<any> {
74
+ const gen = (agent as any).processMessage(msg)
75
+ const events: any[] = []
76
+ for (const evt of gen) {
77
+ events.push(evt)
78
+ }
79
+ return events
80
+ }
81
+
82
+ // ═══════════════════════════════════════════════════════════════════
83
+ // buildArgs — CLI argument construction
84
+ // ═══════════════════════════════════════════════════════════════════
85
+
86
+ describe('buildArgs', () => {
87
+ // --- Core required args ---
88
+
89
+ it('always includes binary, -p, prompt, output-format, verbose, and permission-mode', () => {
90
+ const agent = createAgent()
91
+ const args = callBuildArgs(agent, { prompt: 'Hello world' })
92
+
93
+ expect(args[0]).toBe('claude')
94
+ expect(args).toContain('-p')
95
+ expect(args[args.indexOf('-p') + 1]).toBe('Hello world')
96
+ expect(args).toContain('--output-format')
97
+ expect(args[args.indexOf('--output-format') + 1]).toBe('stream-json')
98
+ expect(args).toContain('--verbose')
99
+ expect(args).toContain('--permission-mode')
100
+ expect(args[args.indexOf('--permission-mode') + 1]).toBe('bypassPermissions')
101
+ })
102
+
103
+ it('uses custom binaryPath when provided', () => {
104
+ const agent = createAgent({ binaryPath: '/usr/local/bin/claude-custom' })
105
+ const args = callBuildArgs(agent, { prompt: 'test' })
106
+
107
+ expect(args[0]).toBe('/usr/local/bin/claude-custom')
108
+ })
109
+
110
+ // --- Session resume ---
111
+
112
+ it('includes --resume flag when sessionId is provided', () => {
113
+ const agent = createAgent()
114
+ const args = callBuildArgs(agent, { prompt: 'continue', sessionId: 'sess_abc123' })
115
+
116
+ expect(args).toContain('--resume')
117
+ expect(args[args.indexOf('--resume') + 1]).toBe('sess_abc123')
118
+ })
119
+
120
+ it('omits --resume flag when no sessionId', () => {
121
+ const agent = createAgent()
122
+ const args = callBuildArgs(agent, { prompt: 'start fresh' })
123
+
124
+ expect(args).not.toContain('--resume')
125
+ })
126
+
127
+ // --- System prompt ---
128
+
129
+ it('includes --append-system-prompt from input', () => {
130
+ const agent = createAgent()
131
+ const args = callBuildArgs(agent, {
132
+ prompt: 'hello',
133
+ systemPrompt: 'You are a helpful assistant',
134
+ })
135
+
136
+ expect(args).toContain('--append-system-prompt')
137
+ expect(args[args.indexOf('--append-system-prompt') + 1]).toBe('You are a helpful assistant')
138
+ })
139
+
140
+ it('includes --append-system-prompt from agent options when input has none', () => {
141
+ const agent = createAgent({ systemPrompt: 'Default system prompt' })
142
+ const args = callBuildArgs(agent, { prompt: 'hello' })
143
+
144
+ expect(args).toContain('--append-system-prompt')
145
+ expect(args[args.indexOf('--append-system-prompt') + 1]).toBe('Default system prompt')
146
+ })
147
+
148
+ it('input systemPrompt overrides agent-level systemPrompt', () => {
149
+ const agent = createAgent({ systemPrompt: 'Agent-level prompt' })
150
+ const args = callBuildArgs(agent, {
151
+ prompt: 'hello',
152
+ systemPrompt: 'Per-query prompt',
153
+ })
154
+
155
+ expect(args[args.indexOf('--append-system-prompt') + 1]).toBe('Per-query prompt')
156
+ })
157
+
158
+ it('omits --append-system-prompt when neither input nor options have one', () => {
159
+ const agent = createAgent()
160
+ const args = callBuildArgs(agent, { prompt: 'hello' })
161
+
162
+ expect(args).not.toContain('--append-system-prompt')
163
+ })
164
+
165
+ // --- Model ---
166
+
167
+ it('includes --model from agent options', () => {
168
+ const agent = createAgent({ model: 'claude-sonnet-4-6' })
169
+ const args = callBuildArgs(agent, { prompt: 'hello' })
170
+
171
+ expect(args).toContain('--model')
172
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-sonnet-4-6')
173
+ })
174
+
175
+ it('input model overrides agent-level model', () => {
176
+ const agent = createAgent({ model: 'claude-sonnet-4-6' })
177
+ const args = callBuildArgs(agent, { prompt: 'hello', model: 'claude-opus-4-6' })
178
+
179
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6')
180
+ })
181
+
182
+ it('omits --model when neither input nor options have one', () => {
183
+ const agent = createAgent()
184
+ const args = callBuildArgs(agent, { prompt: 'hello' })
185
+
186
+ expect(args).not.toContain('--model')
187
+ })
188
+
189
+ // --- Max turns ---
190
+
191
+ it('includes --max-turns when set in options', () => {
192
+ const agent = createAgent({ maxTurns: 10 })
193
+ const args = callBuildArgs(agent, { prompt: 'hello' })
194
+
195
+ expect(args).toContain('--max-turns')
196
+ expect(args[args.indexOf('--max-turns') + 1]).toBe('10')
197
+ })
198
+
199
+ it('omits --max-turns when not set', () => {
200
+ const agent = createAgent()
201
+ const args = callBuildArgs(agent, { prompt: 'hello' })
202
+
203
+ expect(args).not.toContain('--max-turns')
204
+ })
205
+
206
+ // --- Context 1M + model alias resolution ---
207
+
208
+ it('appends [1m] suffix when context1m=true and model is provided', () => {
209
+ const agent = createAgent({ model: 'claude-opus-4-6', context1m: true })
210
+ const args = callBuildArgs(agent, { prompt: 'hello' })
211
+
212
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6[1m]')
213
+ })
214
+
215
+ it('resolves alias before appending [1m] suffix', () => {
216
+ const agent = createAgent({ model: 'opus', context1m: true })
217
+ const args = callBuildArgs(agent, { prompt: 'hello' })
218
+
219
+ // 'opus' alias resolves to 'claude-opus-4-6', then gets [1m]
220
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6[1m]')
221
+ })
222
+
223
+ it('resolves "sonnet" alias with context1m', () => {
224
+ const agent = createAgent({ model: 'sonnet', context1m: true })
225
+ const args = callBuildArgs(agent, { prompt: 'hello' })
226
+
227
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-sonnet-4-6[1m]')
228
+ })
229
+
230
+ it('resolves "haiku" alias with context1m', () => {
231
+ const agent = createAgent({ model: 'haiku', context1m: true })
232
+ const args = callBuildArgs(agent, { prompt: 'hello' })
233
+
234
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-haiku-4-5[1m]')
235
+ })
236
+
237
+ it('resolves "sonnet-4.5" alias with context1m', () => {
238
+ const agent = createAgent({ model: 'sonnet-4.5', context1m: true })
239
+ const args = callBuildArgs(agent, { prompt: 'hello' })
240
+
241
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-sonnet-4-5[1m]')
242
+ })
243
+
244
+ it('does not double-append [1m] if already present', () => {
245
+ const agent = createAgent({ model: 'claude-opus-4-6[1m]', context1m: true })
246
+ const args = callBuildArgs(agent, { prompt: 'hello' })
247
+
248
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6[1m]')
249
+ })
250
+
251
+ it('does not append [1m] when context1m is false', () => {
252
+ const agent = createAgent({ model: 'opus', context1m: false })
253
+ const args = callBuildArgs(agent, { prompt: 'hello' })
254
+
255
+ // Should use the alias as-is (no resolution needed when no [1m])
256
+ expect(args[args.indexOf('--model') + 1]).toBe('opus')
257
+ })
258
+
259
+ it('passes unknown model names through unchanged (without context1m)', () => {
260
+ const agent = createAgent({ model: 'my-custom-model' })
261
+ const args = callBuildArgs(agent, { prompt: 'hello' })
262
+
263
+ expect(args[args.indexOf('--model') + 1]).toBe('my-custom-model')
264
+ })
265
+
266
+ it('passes unknown model names through with [1m] appended (with context1m)', () => {
267
+ const agent = createAgent({ model: 'my-custom-model', context1m: true })
268
+ const args = callBuildArgs(agent, { prompt: 'hello' })
269
+
270
+ expect(args[args.indexOf('--model') + 1]).toBe('my-custom-model[1m]')
271
+ })
272
+
273
+ it('per-query context1m overrides agent-level', () => {
274
+ const agent = createAgent({ model: 'opus', context1m: false })
275
+ const args = callBuildArgs(agent, { prompt: 'hello', context1m: true })
276
+
277
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6[1m]')
278
+ })
279
+
280
+ it('per-query model + context1m together', () => {
281
+ const agent = createAgent({ model: 'haiku' })
282
+ const args = callBuildArgs(agent, { prompt: 'hello', model: 'sonnet', context1m: true })
283
+
284
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-sonnet-4-6[1m]')
285
+ })
286
+
287
+ // --- Full argument order verification ---
288
+
289
+ it('produces expected argument sequence for a full configuration', () => {
290
+ const agent = createAgent({
291
+ model: 'opus',
292
+ maxTurns: 5,
293
+ systemPrompt: 'Be concise',
294
+ context1m: true,
295
+ })
296
+ const args = callBuildArgs(agent, {
297
+ prompt: 'explain recursion',
298
+ sessionId: 'sess_123',
299
+ })
300
+
301
+ // Verify ordering: binary, -p, prompt, output-format, verbose, resume, system-prompt, model, max-turns, permission-mode
302
+ expect(args[0]).toBe('claude')
303
+ expect(args[1]).toBe('-p')
304
+ expect(args[2]).toBe('explain recursion')
305
+ expect(args[3]).toBe('--output-format')
306
+ expect(args[4]).toBe('stream-json')
307
+ expect(args[5]).toBe('--verbose')
308
+ expect(args[6]).toBe('--resume')
309
+ expect(args[7]).toBe('sess_123')
310
+ expect(args[8]).toBe('--append-system-prompt')
311
+ expect(args[9]).toBe('Be concise')
312
+ expect(args[10]).toBe('--model')
313
+ expect(args[11]).toBe('claude-opus-4-6[1m]')
314
+ expect(args[12]).toBe('--max-turns')
315
+ expect(args[13]).toBe('5')
316
+ expect(args[14]).toBe('--permission-mode')
317
+ expect(args[15]).toBe('bypassPermissions')
318
+ expect(args).toHaveLength(16)
319
+ })
320
+ })
321
+
322
+ // ═══════════════════════════════════════════════════════════════════
323
+ // buildEnv — Environment variable filtering
324
+ // ═══════════════════════════════════════════════════════════════════
325
+
326
+ describe('buildEnv', () => {
327
+ // Save original process.env
328
+ const originalEnv = { ...process.env }
329
+
330
+ beforeEach(() => {
331
+ // Clean env for predictable tests
332
+ process.env = {
333
+ HOME: '/home/testuser',
334
+ PATH: '/usr/bin',
335
+ USER: 'testuser',
336
+ ANTHROPIC_API_KEY: 'sk-test-key',
337
+ ANTHROPIC_AUTH_TOKEN: 'oauth-test-token',
338
+ DISCORD_TOKEN: 'discord-secret',
339
+ }
340
+ })
341
+
342
+ // Restore after each test
343
+ const afterEach = () => {
344
+ process.env = originalEnv
345
+ }
346
+
347
+ // --- Auth mode: stored (default) ---
348
+
349
+ describe('auth mode: stored (default)', () => {
350
+ it('removes ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN with security config', () => {
351
+ const agent = createAgent({
352
+ authMode: 'stored',
353
+ security: {
354
+ isolation: 'process',
355
+ envDefaults: ['HOME', 'PATH', 'USER'],
356
+ envPassthrough: [],
357
+ envPassthroughAll: false,
358
+ outputRedactPatterns: [],
359
+ inputSanitization: {
360
+ enabled: true,
361
+ stripMarkers: true,
362
+ logSuspicious: false,
363
+ notifyAgent: false,
364
+ customPatterns: [],
365
+ },
366
+ },
367
+ })
368
+ const env = callBuildEnv(agent)
369
+
370
+ // Auth vars should be explicitly cleared
371
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined()
372
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
373
+ })
374
+
375
+ it('removes auth keys in legacy mode (no security config)', () => {
376
+ const agent = createAgent({ authMode: 'stored' })
377
+ const env = callBuildEnv(agent)
378
+
379
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined()
380
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
381
+ })
382
+ })
383
+
384
+ // --- Auth mode: api-key ---
385
+
386
+ describe('auth mode: api-key', () => {
387
+ it('passes ANTHROPIC_API_KEY through in legacy mode', () => {
388
+ const agent = createAgent({ authMode: 'api-key' })
389
+ const env = callBuildEnv(agent)
390
+
391
+ expect(env.ANTHROPIC_API_KEY).toBe('sk-test-key')
392
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
393
+ })
394
+ })
395
+
396
+ // --- Auth mode: oauth-token ---
397
+
398
+ describe('auth mode: oauth-token', () => {
399
+ it('passes ANTHROPIC_AUTH_TOKEN through in legacy mode', () => {
400
+ const agent = createAgent({ authMode: 'oauth-token' })
401
+ const env = callBuildEnv(agent)
402
+
403
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBe('oauth-test-token')
404
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined()
405
+ })
406
+ })
407
+
408
+ // --- Context 1M ---
409
+
410
+ describe('context1m env control', () => {
411
+ it('sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1 when context1m=false', () => {
412
+ const agent = createAgent({ context1m: false })
413
+ const env = callBuildEnv(agent)
414
+
415
+ expect(env.CLAUDE_CODE_DISABLE_1M_CONTEXT).toBe('1')
416
+ })
417
+
418
+ it('does NOT set disable var when context1m=true', () => {
419
+ const agent = createAgent({ context1m: true })
420
+ const env = callBuildEnv(agent)
421
+
422
+ expect(env.CLAUDE_CODE_DISABLE_1M_CONTEXT).toBeUndefined()
423
+ })
424
+
425
+ it('does NOT set disable var when context1m is undefined', () => {
426
+ const agent = createAgent()
427
+ const env = callBuildEnv(agent)
428
+
429
+ expect(env.CLAUDE_CODE_DISABLE_1M_CONTEXT).toBeUndefined()
430
+ })
431
+ })
432
+
433
+ // --- Security config (allowlist) ---
434
+
435
+ describe('with security config (allowlist mode)', () => {
436
+ it('only passes env vars listed in envDefaults', () => {
437
+ const agent = createAgent({
438
+ security: {
439
+ isolation: 'process',
440
+ envDefaults: ['HOME', 'PATH'],
441
+ envPassthrough: [],
442
+ envPassthroughAll: false,
443
+ outputRedactPatterns: [],
444
+ inputSanitization: {
445
+ enabled: true,
446
+ stripMarkers: true,
447
+ logSuspicious: false,
448
+ notifyAgent: false,
449
+ customPatterns: [],
450
+ },
451
+ },
452
+ })
453
+ const env = callBuildEnv(agent)
454
+
455
+ expect(env.HOME).toBe('/home/testuser')
456
+ expect(env.PATH).toBe('/usr/bin')
457
+ expect(env.DISCORD_TOKEN).toBeUndefined()
458
+ })
459
+
460
+ it('passes custom env vars from options as extraEnv', () => {
461
+ const agent = createAgent({
462
+ env: { CUSTOM_VAR: 'custom_value' },
463
+ security: {
464
+ isolation: 'process',
465
+ envDefaults: ['HOME'],
466
+ envPassthrough: [],
467
+ envPassthroughAll: false,
468
+ outputRedactPatterns: [],
469
+ inputSanitization: {
470
+ enabled: true,
471
+ stripMarkers: true,
472
+ logSuspicious: false,
473
+ notifyAgent: false,
474
+ customPatterns: [],
475
+ },
476
+ },
477
+ })
478
+ const env = callBuildEnv(agent)
479
+
480
+ expect(env.CUSTOM_VAR).toBe('custom_value')
481
+ })
482
+ })
483
+
484
+ // --- Legacy mode (no security config) ---
485
+
486
+ describe('without security config (legacy blocklist mode)', () => {
487
+ it('passes most env vars through', () => {
488
+ const agent = createAgent({ authMode: 'stored' })
489
+ const env = callBuildEnv(agent)
490
+
491
+ expect(env.HOME).toBe('/home/testuser')
492
+ expect(env.PATH).toBe('/usr/bin')
493
+ expect(env.USER).toBe('testuser')
494
+ expect(env.DISCORD_TOKEN).toBe('discord-secret')
495
+ })
496
+
497
+ it('merges custom env vars from options', () => {
498
+ const agent = createAgent({
499
+ authMode: 'stored',
500
+ env: { MY_VAR: 'my_value' },
501
+ })
502
+ const env = callBuildEnv(agent)
503
+
504
+ expect(env.MY_VAR).toBe('my_value')
505
+ })
506
+ })
507
+
508
+ // Cleanup
509
+ afterEach()
510
+ })
511
+
512
+ // ═══════════════════════════════════════════════════════════════════
513
+ // processMessage — JSONL event parsing
514
+ // ═══════════════════════════════════════════════════════════════════
515
+
516
+ describe('processMessage', () => {
517
+ const agent = createAgent()
518
+
519
+ // --- assistant messages ---
520
+
521
+ it('emits text event for assistant text blocks', () => {
522
+ const events = callProcessMessage(agent, {
523
+ type: 'assistant',
524
+ message: {
525
+ role: 'assistant',
526
+ content: [{ type: 'text', text: 'Hello, world!' }],
527
+ },
528
+ })
529
+
530
+ expect(events).toHaveLength(1)
531
+ expect(events[0]).toEqual({ type: 'text', content: 'Hello, world!' })
532
+ })
533
+
534
+ it('emits thinking event for assistant thinking blocks', () => {
535
+ const events = callProcessMessage(agent, {
536
+ type: 'assistant',
537
+ message: {
538
+ role: 'assistant',
539
+ content: [{ type: 'thinking', thinking: 'Let me consider...' }],
540
+ },
541
+ })
542
+
543
+ expect(events).toHaveLength(1)
544
+ expect(events[0]).toEqual({ type: 'thinking', content: 'Let me consider...' })
545
+ })
546
+
547
+ it('emits multiple events for mixed content blocks', () => {
548
+ const events = callProcessMessage(agent, {
549
+ type: 'assistant',
550
+ message: {
551
+ role: 'assistant',
552
+ content: [
553
+ { type: 'thinking', thinking: 'Thinking first...' },
554
+ { type: 'text', text: 'Here is my answer' },
555
+ ],
556
+ },
557
+ })
558
+
559
+ expect(events).toHaveLength(2)
560
+ expect(events[0]).toEqual({ type: 'thinking', content: 'Thinking first...' })
561
+ expect(events[1]).toEqual({ type: 'text', content: 'Here is my answer' })
562
+ })
563
+
564
+ it('skips text blocks with empty text', () => {
565
+ const events = callProcessMessage(agent, {
566
+ type: 'assistant',
567
+ message: {
568
+ role: 'assistant',
569
+ content: [{ type: 'text', text: '' }],
570
+ },
571
+ })
572
+
573
+ expect(events).toHaveLength(0)
574
+ })
575
+
576
+ it('skips assistant messages with no content', () => {
577
+ const events = callProcessMessage(agent, {
578
+ type: 'assistant',
579
+ message: { role: 'assistant' },
580
+ })
581
+
582
+ expect(events).toHaveLength(0)
583
+ })
584
+
585
+ // --- tool_use ---
586
+
587
+ it('emits tool_use event', () => {
588
+ const events = callProcessMessage(agent, {
589
+ type: 'tool_use',
590
+ message: {
591
+ role: 'assistant',
592
+ content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/test.txt' } }],
593
+ },
594
+ })
595
+
596
+ expect(events).toHaveLength(1)
597
+ expect(events[0]).toEqual({
598
+ type: 'tool_use',
599
+ tool: 'Read',
600
+ input: { file_path: '/tmp/test.txt' },
601
+ })
602
+ })
603
+
604
+ it('skips tool_use blocks without a name', () => {
605
+ const events = callProcessMessage(agent, {
606
+ type: 'tool_use',
607
+ message: {
608
+ role: 'assistant',
609
+ content: [{ type: 'tool_use', input: { data: 'test' } }],
610
+ },
611
+ })
612
+
613
+ expect(events).toHaveLength(0)
614
+ })
615
+
616
+ // --- tool_result ---
617
+
618
+ it('emits tool_result event', () => {
619
+ const events = callProcessMessage(agent, {
620
+ type: 'tool_result',
621
+ message: {
622
+ role: 'tool',
623
+ content: [{ type: 'tool_result', content: 'File contents here' }],
624
+ },
625
+ })
626
+
627
+ expect(events).toHaveLength(1)
628
+ expect(events[0]).toEqual({
629
+ type: 'tool_result',
630
+ tool: '',
631
+ output: 'File contents here',
632
+ })
633
+ })
634
+
635
+ // --- error ---
636
+
637
+ it('emits error event', () => {
638
+ const events = callProcessMessage(agent, {
639
+ type: 'error',
640
+ result: 'Something went wrong',
641
+ })
642
+
643
+ expect(events).toHaveLength(1)
644
+ expect(events[0]).toEqual({
645
+ type: 'error',
646
+ error: 'Something went wrong',
647
+ recoverable: false,
648
+ })
649
+ })
650
+
651
+ it('uses fallback message for error without result', () => {
652
+ const events = callProcessMessage(agent, {
653
+ type: 'error',
654
+ })
655
+
656
+ expect(events).toHaveLength(1)
657
+ expect(events[0]).toEqual({
658
+ type: 'error',
659
+ error: 'Unknown CLI error',
660
+ recoverable: false,
661
+ })
662
+ })
663
+
664
+ // --- result (final) ---
665
+
666
+ it('emits nothing for result type (handled externally)', () => {
667
+ const events = callProcessMessage(agent, {
668
+ type: 'result',
669
+ result: 'Final summary',
670
+ session_id: 'sess_xyz',
671
+ })
672
+
673
+ expect(events).toHaveLength(0)
674
+ })
675
+
676
+ // --- unknown types ---
677
+
678
+ it('emits nothing for unknown message types', () => {
679
+ const events = callProcessMessage(agent, {
680
+ type: 'system',
681
+ message: { content: 'system info' },
682
+ })
683
+
684
+ expect(events).toHaveLength(0)
685
+ })
686
+ })
687
+
688
+ // ═══════════════════════════════════════════════════════════════════
689
+ // Constructor and metadata
690
+ // ═══════════════════════════════════════════════════════════════════
691
+
692
+ describe('ClaudeAgent — constructor and metadata', () => {
693
+ it('has correct id and name', () => {
694
+ const agent = createAgent()
695
+ expect(agent.id).toBe('claude')
696
+ expect(agent.name).toBe('Claude Code (CLI)')
697
+ })
698
+
699
+ it('stores options internally', () => {
700
+ const agent = createAgent({
701
+ cwd: '/my/workspace',
702
+ model: 'opus',
703
+ maxTurns: 5,
704
+ })
705
+
706
+ // Verify options are stored by checking buildArgs output
707
+ const args = callBuildArgs(agent, { prompt: 'test' })
708
+ expect(args).toContain('--model')
709
+ expect(args).toContain('--max-turns')
710
+ })
711
+ })
712
+
713
+ // ═══════════════════════════════════════════════════════════════════
714
+ // destroy — Process cleanup
715
+ // ═══════════════════════════════════════════════════════════════════
716
+
717
+ describe('ClaudeAgent — destroy', () => {
718
+ it('sets activeProcess to null', async () => {
719
+ const agent = createAgent()
720
+
721
+ // No active process — should not throw
722
+ await agent.destroy()
723
+ expect((agent as any).activeProcess).toBeNull()
724
+ })
725
+
726
+ it('kills active process if present', async () => {
727
+ const agent = createAgent()
728
+ const killMock = mock(() => {})
729
+
730
+ // Simulate an active process
731
+ ;(agent as any).activeProcess = { kill: killMock }
732
+
733
+ await agent.destroy()
734
+ expect(killMock).toHaveBeenCalledTimes(1)
735
+ expect((agent as any).activeProcess).toBeNull()
736
+ })
737
+ })
738
+
739
+ // ═══════════════════════════════════════════════════════════════════
740
+ // Model alias resolution (tested indirectly through buildArgs)
741
+ // ═══════════════════════════════════════════════════════════════════
742
+
743
+ describe('model alias resolution', () => {
744
+ it('opus → claude-opus-4-6 (only with context1m)', () => {
745
+ const agent = createAgent({ model: 'opus', context1m: true })
746
+ const args = callBuildArgs(agent, { prompt: 'test' })
747
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6[1m]')
748
+ })
749
+
750
+ it('sonnet → claude-sonnet-4-6 (only with context1m)', () => {
751
+ const agent = createAgent({ model: 'sonnet', context1m: true })
752
+ const args = callBuildArgs(agent, { prompt: 'test' })
753
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-sonnet-4-6[1m]')
754
+ })
755
+
756
+ it('haiku → claude-haiku-4-5 (only with context1m)', () => {
757
+ const agent = createAgent({ model: 'haiku', context1m: true })
758
+ const args = callBuildArgs(agent, { prompt: 'test' })
759
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-haiku-4-5[1m]')
760
+ })
761
+
762
+ it('sonnet-4.5 → claude-sonnet-4-5 (only with context1m)', () => {
763
+ const agent = createAgent({ model: 'sonnet-4.5', context1m: true })
764
+ const args = callBuildArgs(agent, { prompt: 'test' })
765
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-sonnet-4-5[1m]')
766
+ })
767
+
768
+ it('full model ID passes through unchanged (without context1m)', () => {
769
+ const agent = createAgent({ model: 'claude-opus-4-6' })
770
+ const args = callBuildArgs(agent, { prompt: 'test' })
771
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6')
772
+ })
773
+
774
+ it('full model ID passes through with [1m] appended (with context1m)', () => {
775
+ const agent = createAgent({ model: 'claude-opus-4-6', context1m: true })
776
+ const args = callBuildArgs(agent, { prompt: 'test' })
777
+ expect(args[args.indexOf('--model') + 1]).toBe('claude-opus-4-6[1m]')
778
+ })
779
+ })
780
+
781
+ // ═══════════════════════════════════════════════════════════════════
782
+ // Mock Bun.spawn helper for init / health / query tests
783
+ // ═══════════════════════════════════════════════════════════════════
784
+
785
+ /**
786
+ * Creates a mock Bun.spawn result with controllable stdout/stderr/exit.
787
+ * For streaming tests, stdout is a ReadableStream of JSONL lines.
788
+ */
789
+ function createMockProcess(opts: { exitCode?: number; stdout?: string; stderr?: string } = {}) {
790
+ const { exitCode = 0, stdout = '', stderr = '' } = opts
791
+
792
+ return {
793
+ stdout: new Response(stdout).body!,
794
+ stderr: new Response(stderr).body!,
795
+ exited: Promise.resolve(exitCode),
796
+ kill: mock(() => {}),
797
+ pid: 12345,
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Creates a mock Bun.spawn result with streaming stdout for query() tests.
803
+ * Each line in `lines` becomes a separate JSONL line in the stream.
804
+ */
805
+ function createStreamingMockProcess(lines: string[], exitCode = 0) {
806
+ const body = lines.join('\n') + '\n'
807
+ return {
808
+ stdout: new ReadableStream({
809
+ start(controller) {
810
+ controller.enqueue(new TextEncoder().encode(body))
811
+ controller.close()
812
+ },
813
+ }),
814
+ stderr: new Response('').body!,
815
+ exited: Promise.resolve(exitCode),
816
+ kill: mock(() => {}),
817
+ pid: 12345,
818
+ }
819
+ }
820
+
821
+ // ═══════════════════════════════════════════════════════════════════
822
+ // init — Binary check and auth verification
823
+ // ═══════════════════════════════════════════════════════════════════
824
+
825
+ describe('ClaudeAgent — init', () => {
826
+ it('succeeds when binary exists and auth check passes', async () => {
827
+ const agent = createAgent()
828
+
829
+ const spawnSpy = spyOn(Bun, 'spawn')
830
+
831
+ // First call: `which claude` (exit 0)
832
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 0 }) as any)
833
+ // Second call: auth check `claude -p "Reply with OK" ...` (exit 0, valid JSON)
834
+ spawnSpy.mockReturnValueOnce(
835
+ createMockProcess({
836
+ exitCode: 0,
837
+ stdout: JSON.stringify({ result: 'OK' }),
838
+ }) as any,
839
+ )
840
+
841
+ await agent.init() // should not throw
842
+
843
+ expect(spawnSpy).toHaveBeenCalledTimes(2)
844
+ spawnSpy.mockRestore()
845
+ })
846
+
847
+ it('throws when binary is not found (which returns non-zero)', async () => {
848
+ const agent = createAgent({ binaryPath: '/nonexistent/claude' })
849
+
850
+ const spawnSpy = spyOn(Bun, 'spawn')
851
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 1 }) as any)
852
+
853
+ await expect(agent.init()).rejects.toThrow('Claude CLI not found')
854
+
855
+ spawnSpy.mockRestore()
856
+ })
857
+
858
+ it('throws when auth check process exits non-zero', async () => {
859
+ const agent = createAgent()
860
+
861
+ const spawnSpy = spyOn(Bun, 'spawn')
862
+ // which succeeds
863
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 0 }) as any)
864
+ // auth check fails
865
+ spawnSpy.mockReturnValueOnce(
866
+ createMockProcess({
867
+ exitCode: 1,
868
+ stderr: 'Authentication failed: invalid credentials',
869
+ }) as any,
870
+ )
871
+
872
+ await expect(agent.init()).rejects.toThrow('auth check failed')
873
+
874
+ spawnSpy.mockRestore()
875
+ })
876
+
877
+ it('throws when auth check response indicates invalid API key', async () => {
878
+ const agent = createAgent()
879
+
880
+ const spawnSpy = spyOn(Bun, 'spawn')
881
+ // which succeeds
882
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 0 }) as any)
883
+ // auth check returns error JSON
884
+ spawnSpy.mockReturnValueOnce(
885
+ createMockProcess({
886
+ exitCode: 0,
887
+ stdout: JSON.stringify({ is_error: true, result: 'Invalid API key' }),
888
+ }) as any,
889
+ )
890
+
891
+ await expect(agent.init()).rejects.toThrow('auth failed')
892
+
893
+ spawnSpy.mockRestore()
894
+ })
895
+
896
+ it('proceeds when auth check output is not valid JSON', async () => {
897
+ const agent = createAgent()
898
+
899
+ const spawnSpy = spyOn(Bun, 'spawn')
900
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 0 }) as any)
901
+ spawnSpy.mockReturnValueOnce(
902
+ createMockProcess({
903
+ exitCode: 0,
904
+ stdout: 'not json at all',
905
+ }) as any,
906
+ )
907
+
908
+ // Should not throw — the SyntaxError is caught and logged as warning
909
+ await agent.init()
910
+
911
+ spawnSpy.mockRestore()
912
+ })
913
+ })
914
+
915
+ // ═══════════════════════════════════════════════════════════════════
916
+ // health — Version check
917
+ // ═══════════════════════════════════════════════════════════════════
918
+
919
+ describe('ClaudeAgent — health', () => {
920
+ it('returns ok=true and latencyMs when --version succeeds', async () => {
921
+ const agent = createAgent()
922
+
923
+ const spawnSpy = spyOn(Bun, 'spawn')
924
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 0 }) as any)
925
+
926
+ const result = await agent.health()
927
+
928
+ expect(result.ok).toBe(true)
929
+ expect(result.latencyMs).toBeGreaterThanOrEqual(0)
930
+ expect(result.error).toBeUndefined()
931
+
932
+ spawnSpy.mockRestore()
933
+ })
934
+
935
+ it('returns ok=false with exit code error when --version fails', async () => {
936
+ const agent = createAgent()
937
+
938
+ const spawnSpy = spyOn(Bun, 'spawn')
939
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 127 }) as any)
940
+
941
+ const result = await agent.health()
942
+
943
+ expect(result.ok).toBe(false)
944
+ expect(result.error).toBe('Exit code 127')
945
+ expect(result.latencyMs).toBeGreaterThanOrEqual(0)
946
+
947
+ spawnSpy.mockRestore()
948
+ })
949
+
950
+ it('returns ok=false with error message when spawn throws', async () => {
951
+ const agent = createAgent()
952
+
953
+ const spawnSpy = spyOn(Bun, 'spawn')
954
+ spawnSpy.mockImplementationOnce(() => {
955
+ throw new Error('ENOENT: no such file')
956
+ })
957
+
958
+ const result = await agent.health()
959
+
960
+ expect(result.ok).toBe(false)
961
+ expect(result.error).toBe('ENOENT: no such file')
962
+
963
+ spawnSpy.mockRestore()
964
+ })
965
+
966
+ it('uses custom binaryPath for version check', async () => {
967
+ const agent = createAgent({ binaryPath: '/opt/claude-beta' })
968
+
969
+ const spawnSpy = spyOn(Bun, 'spawn')
970
+ spawnSpy.mockReturnValueOnce(createMockProcess({ exitCode: 0 }) as any)
971
+
972
+ await agent.health()
973
+
974
+ const firstArg = spawnSpy.mock.calls[0][0] as string[]
975
+ expect(firstArg[0]).toBe('/opt/claude-beta')
976
+
977
+ spawnSpy.mockRestore()
978
+ })
979
+ })
980
+
981
+ // ═══════════════════════════════════════════════════════════════════
982
+ // query — Async generator with streaming JSON output
983
+ // ═══════════════════════════════════════════════════════════════════
984
+
985
+ describe('ClaudeAgent — query', () => {
986
+ it('yields text events from assistant messages', async () => {
987
+ const agent = createAgent()
988
+ const spawnSpy = spyOn(Bun, 'spawn')
989
+
990
+ const lines = [
991
+ JSON.stringify({
992
+ type: 'assistant',
993
+ message: {
994
+ role: 'assistant',
995
+ content: [{ type: 'text', text: 'Hello from Claude!' }],
996
+ usage: { input_tokens: 100, output_tokens: 50 },
997
+ model: 'claude-sonnet-4-6',
998
+ },
999
+ session_id: 'sess_abc',
1000
+ }),
1001
+ JSON.stringify({
1002
+ type: 'result',
1003
+ result: 'Hello from Claude!',
1004
+ session_id: 'sess_abc',
1005
+ }),
1006
+ ]
1007
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1008
+
1009
+ const events: any[] = []
1010
+ for await (const evt of agent.query({ prompt: 'Hi' })) {
1011
+ events.push(evt)
1012
+ }
1013
+
1014
+ // Should have text + done events
1015
+ const textEvents = events.filter((e) => e.type === 'text')
1016
+ expect(textEvents).toHaveLength(1)
1017
+ expect(textEvents[0].content).toBe('Hello from Claude!')
1018
+
1019
+ const doneEvents = events.filter((e) => e.type === 'done')
1020
+ expect(doneEvents).toHaveLength(1)
1021
+ expect(doneEvents[0].sessionId).toBe('sess_abc')
1022
+ expect(doneEvents[0].usage.inputTokens).toBe(100)
1023
+ expect(doneEvents[0].usage.outputTokens).toBe(50)
1024
+ expect(doneEvents[0].usage.model).toBe('claude-sonnet-4-6')
1025
+
1026
+ spawnSpy.mockRestore()
1027
+ })
1028
+
1029
+ it('yields thinking events from thinking blocks', async () => {
1030
+ const agent = createAgent()
1031
+ const spawnSpy = spyOn(Bun, 'spawn')
1032
+
1033
+ const lines = [
1034
+ JSON.stringify({
1035
+ type: 'assistant',
1036
+ message: {
1037
+ role: 'assistant',
1038
+ content: [
1039
+ { type: 'thinking', thinking: 'Let me reason about this...' },
1040
+ { type: 'text', text: 'The answer is 42.' },
1041
+ ],
1042
+ usage: { input_tokens: 200, output_tokens: 80 },
1043
+ },
1044
+ }),
1045
+ JSON.stringify({ type: 'result', session_id: 'sess_xyz' }),
1046
+ ]
1047
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1048
+
1049
+ const events: any[] = []
1050
+ for await (const evt of agent.query({ prompt: 'Explain' })) {
1051
+ events.push(evt)
1052
+ }
1053
+
1054
+ expect(events.filter((e) => e.type === 'thinking')).toHaveLength(1)
1055
+ expect(events.filter((e) => e.type === 'thinking')[0].content).toBe(
1056
+ 'Let me reason about this...',
1057
+ )
1058
+ expect(events.filter((e) => e.type === 'text')).toHaveLength(1)
1059
+
1060
+ spawnSpy.mockRestore()
1061
+ })
1062
+
1063
+ it('yields tool_use and tool_result events', async () => {
1064
+ const agent = createAgent()
1065
+ const spawnSpy = spyOn(Bun, 'spawn')
1066
+
1067
+ const lines = [
1068
+ JSON.stringify({
1069
+ type: 'tool_use',
1070
+ message: {
1071
+ role: 'assistant',
1072
+ content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/test.txt' } }],
1073
+ },
1074
+ }),
1075
+ JSON.stringify({
1076
+ type: 'tool_result',
1077
+ message: {
1078
+ role: 'tool',
1079
+ content: [{ type: 'tool_result', content: 'File contents here' }],
1080
+ },
1081
+ }),
1082
+ JSON.stringify({
1083
+ type: 'assistant',
1084
+ message: {
1085
+ role: 'assistant',
1086
+ content: [{ type: 'text', text: 'I read the file.' }],
1087
+ usage: { input_tokens: 300, output_tokens: 20 },
1088
+ },
1089
+ session_id: 'sess_tools',
1090
+ }),
1091
+ JSON.stringify({ type: 'result', session_id: 'sess_tools' }),
1092
+ ]
1093
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1094
+
1095
+ const events: any[] = []
1096
+ for await (const evt of agent.query({ prompt: 'Read file' })) {
1097
+ events.push(evt)
1098
+ }
1099
+
1100
+ const toolUse = events.filter((e) => e.type === 'tool_use')
1101
+ expect(toolUse).toHaveLength(1)
1102
+ expect(toolUse[0].tool).toBe('Read')
1103
+
1104
+ const toolResult = events.filter((e) => e.type === 'tool_result')
1105
+ expect(toolResult).toHaveLength(1)
1106
+ expect(toolResult[0].output).toBe('File contents here')
1107
+
1108
+ spawnSpy.mockRestore()
1109
+ })
1110
+
1111
+ it('yields error event on non-zero exit with stderr', async () => {
1112
+ const agent = createAgent()
1113
+ const spawnSpy = spyOn(Bun, 'spawn')
1114
+
1115
+ // Empty stdout, non-zero exit with stderr
1116
+ spawnSpy.mockReturnValueOnce({
1117
+ stdout: new ReadableStream({
1118
+ start(controller) {
1119
+ controller.close()
1120
+ },
1121
+ }),
1122
+ stderr: new Response('Fatal: rate limit exceeded').body!,
1123
+ exited: Promise.resolve(1),
1124
+ kill: mock(() => {}),
1125
+ pid: 99999,
1126
+ } as any)
1127
+
1128
+ const events: any[] = []
1129
+ for await (const evt of agent.query({ prompt: 'test' })) {
1130
+ events.push(evt)
1131
+ }
1132
+
1133
+ const errors = events.filter((e) => e.type === 'error')
1134
+ expect(errors.length).toBeGreaterThanOrEqual(1)
1135
+
1136
+ spawnSpy.mockRestore()
1137
+ })
1138
+
1139
+ it('yields rate-limit recoverable error for rate limit stderr', async () => {
1140
+ const agent = createAgent()
1141
+ const spawnSpy = spyOn(Bun, 'spawn')
1142
+
1143
+ spawnSpy.mockReturnValueOnce({
1144
+ stdout: new ReadableStream({
1145
+ start(controller) {
1146
+ controller.close()
1147
+ },
1148
+ }),
1149
+ stderr: new Response("you've hit your limit for today").body!,
1150
+ exited: Promise.resolve(1),
1151
+ kill: mock(() => {}),
1152
+ pid: 99999,
1153
+ } as any)
1154
+
1155
+ const events: any[] = []
1156
+ for await (const evt of agent.query({ prompt: 'test' })) {
1157
+ events.push(evt)
1158
+ }
1159
+
1160
+ const errors = events.filter((e) => e.type === 'error')
1161
+ expect(errors.length).toBeGreaterThanOrEqual(1)
1162
+ expect(errors[0].recoverable).toBe(true)
1163
+
1164
+ spawnSpy.mockRestore()
1165
+ })
1166
+
1167
+ it('accumulates token usage across multiple messages', async () => {
1168
+ const agent = createAgent()
1169
+ const spawnSpy = spyOn(Bun, 'spawn')
1170
+
1171
+ const lines = [
1172
+ JSON.stringify({
1173
+ type: 'assistant',
1174
+ message: {
1175
+ role: 'assistant',
1176
+ content: [{ type: 'text', text: 'Step 1' }],
1177
+ usage: {
1178
+ input_tokens: 100,
1179
+ output_tokens: 50,
1180
+ cache_read_input_tokens: 10,
1181
+ cache_creation_input_tokens: 5,
1182
+ },
1183
+ model: 'claude-sonnet-4-6',
1184
+ },
1185
+ session_id: 'sess_multi',
1186
+ }),
1187
+ JSON.stringify({
1188
+ type: 'assistant',
1189
+ message: {
1190
+ role: 'assistant',
1191
+ content: [{ type: 'text', text: 'Step 2' }],
1192
+ usage: {
1193
+ input_tokens: 200,
1194
+ output_tokens: 60,
1195
+ cache_read_input_tokens: 20,
1196
+ cache_creation_input_tokens: 8,
1197
+ },
1198
+ model: 'claude-sonnet-4-6',
1199
+ },
1200
+ }),
1201
+ JSON.stringify({ type: 'result', session_id: 'sess_multi' }),
1202
+ ]
1203
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1204
+
1205
+ const events: any[] = []
1206
+ for await (const evt of agent.query({ prompt: 'multi-step' })) {
1207
+ events.push(evt)
1208
+ }
1209
+
1210
+ const done = events.find((e) => e.type === 'done')
1211
+ expect(done.usage.inputTokens).toBe(300) // 100 + 200
1212
+ expect(done.usage.outputTokens).toBe(110) // 50 + 60
1213
+ expect(done.usage.cacheReadTokens).toBe(30) // 10 + 20
1214
+ expect(done.usage.cacheWriteTokens).toBe(13) // 5 + 8
1215
+ // contextTokens = last call only: 200 + 20 + 8 = 228
1216
+ expect(done.usage.contextTokens).toBe(228)
1217
+
1218
+ spawnSpy.mockRestore()
1219
+ })
1220
+
1221
+ it('falls back to result text when no assistant text was emitted', async () => {
1222
+ const agent = createAgent()
1223
+ const spawnSpy = spyOn(Bun, 'spawn')
1224
+
1225
+ const lines = [
1226
+ JSON.stringify({
1227
+ type: 'result',
1228
+ result: 'Direct result text',
1229
+ session_id: 'sess_fallback',
1230
+ }),
1231
+ ]
1232
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1233
+
1234
+ const events: any[] = []
1235
+ for await (const evt of agent.query({ prompt: 'quick' })) {
1236
+ events.push(evt)
1237
+ }
1238
+
1239
+ const textEvents = events.filter((e) => e.type === 'text')
1240
+ expect(textEvents).toHaveLength(1)
1241
+ expect(textEvents[0].content).toBe('Direct result text')
1242
+
1243
+ spawnSpy.mockRestore()
1244
+ })
1245
+
1246
+ it('skips non-JSON lines gracefully', async () => {
1247
+ const agent = createAgent()
1248
+ const spawnSpy = spyOn(Bun, 'spawn')
1249
+
1250
+ const lines = [
1251
+ 'some random log output',
1252
+ JSON.stringify({
1253
+ type: 'assistant',
1254
+ message: {
1255
+ role: 'assistant',
1256
+ content: [{ type: 'text', text: 'Valid response' }],
1257
+ usage: { input_tokens: 10, output_tokens: 5 },
1258
+ },
1259
+ session_id: 'sess_skip',
1260
+ }),
1261
+ 'another non-json line',
1262
+ JSON.stringify({ type: 'result', session_id: 'sess_skip' }),
1263
+ ]
1264
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1265
+
1266
+ const events: any[] = []
1267
+ for await (const evt of agent.query({ prompt: 'test' })) {
1268
+ events.push(evt)
1269
+ }
1270
+
1271
+ const textEvents = events.filter((e) => e.type === 'text')
1272
+ expect(textEvents).toHaveLength(1)
1273
+ expect(textEvents[0].content).toBe('Valid response')
1274
+
1275
+ spawnSpy.mockRestore()
1276
+ })
1277
+
1278
+ it('clears activeProcess in finally block', async () => {
1279
+ const agent = createAgent()
1280
+ const spawnSpy = spyOn(Bun, 'spawn')
1281
+
1282
+ const lines = [
1283
+ JSON.stringify({
1284
+ type: 'assistant',
1285
+ message: {
1286
+ role: 'assistant',
1287
+ content: [{ type: 'text', text: 'Done' }],
1288
+ usage: { input_tokens: 1, output_tokens: 1 },
1289
+ },
1290
+ session_id: 'sess_fin',
1291
+ }),
1292
+ JSON.stringify({ type: 'result', session_id: 'sess_fin' }),
1293
+ ]
1294
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1295
+
1296
+ for await (const _evt of agent.query({ prompt: 'test' })) {
1297
+ // consume all events
1298
+ }
1299
+
1300
+ expect((agent as any).activeProcess).toBeNull()
1301
+
1302
+ spawnSpy.mockRestore()
1303
+ })
1304
+
1305
+ it('tags model with [1m] suffix when context1m is active', async () => {
1306
+ const agent = createAgent({ context1m: true })
1307
+ const spawnSpy = spyOn(Bun, 'spawn')
1308
+
1309
+ const lines = [
1310
+ JSON.stringify({
1311
+ type: 'assistant',
1312
+ message: {
1313
+ role: 'assistant',
1314
+ content: [{ type: 'text', text: 'Extended context response' }],
1315
+ usage: { input_tokens: 500, output_tokens: 100 },
1316
+ model: 'claude-opus-4-6',
1317
+ },
1318
+ session_id: 'sess_1m',
1319
+ }),
1320
+ JSON.stringify({ type: 'result', session_id: 'sess_1m' }),
1321
+ ]
1322
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1323
+
1324
+ const events: any[] = []
1325
+ for await (const evt of agent.query({ prompt: 'big context' })) {
1326
+ events.push(evt)
1327
+ }
1328
+
1329
+ const done = events.find((e) => e.type === 'done')
1330
+ expect(done.usage.model).toBe('claude-opus-4-6[1m]')
1331
+
1332
+ spawnSpy.mockRestore()
1333
+ })
1334
+
1335
+ it('returns empty sessionId when no assistant text or output tokens', async () => {
1336
+ const agent = createAgent()
1337
+ const spawnSpy = spyOn(Bun, 'spawn')
1338
+
1339
+ const lines = [
1340
+ JSON.stringify({
1341
+ type: 'error',
1342
+ result: 'Authentication error',
1343
+ }),
1344
+ JSON.stringify({ type: 'result', session_id: 'sess_invalid' }),
1345
+ ]
1346
+ spawnSpy.mockReturnValueOnce(createStreamingMockProcess(lines) as any)
1347
+
1348
+ const events: any[] = []
1349
+ for await (const evt of agent.query({ prompt: 'test' })) {
1350
+ events.push(evt)
1351
+ }
1352
+
1353
+ const done = events.find((e) => e.type === 'done')
1354
+ // No assistant text and no output tokens → invalid session
1355
+ expect(done.sessionId).toBe('')
1356
+
1357
+ spawnSpy.mockRestore()
1358
+ })
1359
+ })
1360
+
1361
+ // ═══════════════════════════════════════════════════════════════════
1362
+ // Configurable id and name (Phase B — multi-agent routing)
1363
+ // ═══════════════════════════════════════════════════════════════════
1364
+
1365
+ describe('configurable id and name (Phase B)', () => {
1366
+ it('defaults to id=claude and name=Claude Code (CLI) when not specified', () => {
1367
+ const agent = createAgent()
1368
+ expect(agent.id).toBe('claude')
1369
+ expect(agent.name).toBe('Claude Code (CLI)')
1370
+ })
1371
+
1372
+ it('uses custom id and name from constructor options', () => {
1373
+ const agent = createAgent({ id: 'hermes', name: 'Hermes Agent' })
1374
+ expect(agent.id).toBe('hermes')
1375
+ expect(agent.name).toBe('Hermes Agent')
1376
+ })
1377
+
1378
+ it('uses custom id with default name', () => {
1379
+ const agent = createAgent({ id: 'deimos' })
1380
+ expect(agent.id).toBe('deimos')
1381
+ expect(agent.name).toBe('Claude Code (CLI)')
1382
+ })
1383
+ })