@axiom-lattice/cli-a2a 0.1.4 → 0.1.6

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 (65) hide show
  1. package/.turbo/turbo-build.log +34 -32
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +85 -2
  4. package/__tests__/opencode-executor.test.ts +631 -31
  5. package/__tests__/session-store-path.test.ts +40 -0
  6. package/dist/{chunk-NQIDRU47.mjs → chunk-54SKDK3D.mjs} +78 -32
  7. package/dist/chunk-54SKDK3D.mjs.map +1 -0
  8. package/dist/chunk-BSNQOP2W.mjs +350 -0
  9. package/dist/chunk-BSNQOP2W.mjs.map +1 -0
  10. package/dist/chunk-DRFFQJII.mjs +195 -0
  11. package/dist/chunk-DRFFQJII.mjs.map +1 -0
  12. package/dist/chunk-URJDRXJW.mjs +378 -0
  13. package/dist/chunk-URJDRXJW.mjs.map +1 -0
  14. package/dist/{chunk-G7AGL2QA.mjs → chunk-WZB7FBSX.mjs} +23 -5
  15. package/dist/chunk-WZB7FBSX.mjs.map +1 -0
  16. package/dist/{chunk-LXL47XMZ.mjs → chunk-YGUPSPZO.mjs} +9 -1
  17. package/dist/chunk-YGUPSPZO.mjs.map +1 -0
  18. package/dist/{chunk-35NFMGMS.mjs → chunk-ZP3JNJQJ.mjs} +3 -3
  19. package/dist/chunk-ZP3JNJQJ.mjs.map +1 -0
  20. package/dist/cli.js +769 -156
  21. package/dist/cli.js.map +1 -1
  22. package/dist/cli.mjs +7 -6
  23. package/dist/cli.mjs.map +1 -1
  24. package/dist/executor-LLE5VEFJ.mjs +12 -0
  25. package/dist/executor-LO7P3KUW.mjs +12 -0
  26. package/dist/{executor-RVBGAWUF.mjs → executor-OOLGGXHS.mjs} +4 -3
  27. package/dist/{executors-QIIKBUMJ.mjs → executors-AQHOPGSR.mjs} +2 -2
  28. package/dist/index.d.mts +92 -21
  29. package/dist/index.d.ts +92 -21
  30. package/dist/index.js +731 -123
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +15 -7
  33. package/package.json +11 -4
  34. package/runtime/cli-path.cjs +3 -0
  35. package/runtime/cli-path.d.ts +1 -0
  36. package/runtime/cli-path.mjs +5 -0
  37. package/src/config/defaults.ts +3 -1
  38. package/src/config/types.ts +8 -3
  39. package/src/executors/claude/client.ts +44 -14
  40. package/src/executors/claude/executor.ts +65 -30
  41. package/src/executors/codex/client.ts +239 -16
  42. package/src/executors/codex/executor.ts +70 -37
  43. package/src/executors/events.ts +15 -1
  44. package/src/executors/index.ts +13 -6
  45. package/src/executors/opencode/client.ts +192 -18
  46. package/src/executors/opencode/executor.ts +65 -30
  47. package/src/index.ts +8 -0
  48. package/src/local-state.ts +53 -0
  49. package/src/server/agent-card.ts +1 -0
  50. package/src/server/index.ts +22 -7
  51. package/src/session-store.ts +262 -0
  52. package/dist/chunk-35NFMGMS.mjs.map +0 -1
  53. package/dist/chunk-G7AGL2QA.mjs.map +0 -1
  54. package/dist/chunk-LXL47XMZ.mjs.map +0 -1
  55. package/dist/chunk-NQIDRU47.mjs.map +0 -1
  56. package/dist/chunk-VSZ3DACI.mjs +0 -179
  57. package/dist/chunk-VSZ3DACI.mjs.map +0 -1
  58. package/dist/chunk-WNCDOYZS.mjs +0 -187
  59. package/dist/chunk-WNCDOYZS.mjs.map +0 -1
  60. package/dist/executor-OWPVDUXH.mjs +0 -11
  61. package/dist/executor-XWHWUVQ3.mjs +0 -11
  62. /package/dist/{executor-OWPVDUXH.mjs.map → executor-LLE5VEFJ.mjs.map} +0 -0
  63. /package/dist/{executor-RVBGAWUF.mjs.map → executor-LO7P3KUW.mjs.map} +0 -0
  64. /package/dist/{executor-XWHWUVQ3.mjs.map → executor-OOLGGXHS.mjs.map} +0 -0
  65. /package/dist/{executors-QIIKBUMJ.mjs.map → executors-AQHOPGSR.mjs.map} +0 -0
@@ -1,14 +1,35 @@
1
1
  /**
2
- * Tests for OpenCode A2A Executor.
2
+ * Tests for CLI A2A multi-turn conversation support.
3
3
  *
4
- * These are unit tests that mock the OpenCode SDK.
5
- * Integration tests require a running opencode server.
4
+ * Covers SessionBindingStore, client session arguments, and executor
5
+ * contextId→sessionId mapping.
6
6
  */
7
7
 
8
+ import { SessionBindingStore, generateContextId } from '../src/session-store';
8
9
  import { OpenCodeExecutor } from '../src/executors/opencode/executor';
10
+ import { CodexExecutor } from '../src/executors/codex/executor';
11
+ import { ClaudeExecutor } from '../src/executors/claude/executor';
12
+ import { OpenCodeClient } from '../src/executors/opencode/client';
13
+ import { CodexClient } from '../src/executors/codex/client';
14
+ import { ClaudeClient } from '../src/executors/claude/client';
9
15
  import type { AgentConfig } from '../src/config/types';
10
16
  import { resolveDefaults } from '../src/config/defaults';
11
17
 
18
+ import { mkdirSync, writeFileSync, rmSync, unlinkSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { randomUUID } from 'node:crypto';
22
+
23
+ // ─── Helpers ────────────────────────────────────────────────────────────────
24
+
25
+ function createTestStoreDir(): string {
26
+ const dir = join(tmpdir(), `cli-a2a-test-${randomUUID()}`);
27
+ mkdirSync(dir, { recursive: true });
28
+ return dir;
29
+ }
30
+
31
+ let tmpDir: string;
32
+
12
33
  function createMockConfig(overrides?: Partial<AgentConfig>): Required<AgentConfig> {
13
34
  const base: Partial<AgentConfig> = {
14
35
  provider: 'opencode',
@@ -22,11 +43,20 @@ function createMockConfig(overrides?: Partial<AgentConfig>): Required<AgentConfi
22
43
  model: 'anthropic/claude-sonnet-4',
23
44
  agent: 'code-assistant',
24
45
  },
46
+ codex: {
47
+ cliPath: 'codex',
48
+ workdir: '/tmp/test-workspace',
49
+ model: 'gpt-4o',
50
+ },
51
+ claude: {
52
+ cliPath: 'claude',
53
+ workdir: '/tmp/test-workspace',
54
+ model: 'claude-sonnet-4',
55
+ },
25
56
  session: {
26
57
  ttl: 3_600_000,
27
58
  cleanupInterval: 300_000,
28
59
  reuseByContext: true,
29
- titlePrefix: 'A2A Test',
30
60
  },
31
61
  features: {
32
62
  autoApprovePermissions: true,
@@ -53,49 +83,402 @@ function createMockConfig(overrides?: Partial<AgentConfig>): Required<AgentConfi
53
83
  return resolveDefaults(base);
54
84
  }
55
85
 
86
+ function createStore(opts?: { persist?: boolean }): SessionBindingStore {
87
+ return new SessionBindingStore({
88
+ persist: opts?.persist ?? false,
89
+ cleanupIntervalMs: 100_000, // don't actually run
90
+ });
91
+ }
92
+
93
+ // ─── SessionBindingStore ────────────────────────────────────────────────────
94
+
95
+ describe('SessionBindingStore', () => {
96
+ let store: SessionBindingStore;
97
+
98
+ beforeEach(() => {
99
+ store = createStore();
100
+ });
101
+
102
+ afterEach(() => {
103
+ store.shutdown();
104
+ });
105
+
106
+ describe('basic CRUD', () => {
107
+ it('should return null for unknown contextId', () => {
108
+ expect(store.get('unknown', 'codex')).toBeNull();
109
+ });
110
+
111
+ it('should set and get a binding', () => {
112
+ const binding = {
113
+ provider: 'codex' as const,
114
+ contextId: 'ctx-1',
115
+ sessionId: 'ses-1',
116
+ activeTaskId: 'task-1',
117
+ activeTaskState: 'working',
118
+ createdAt: new Date().toISOString(),
119
+ updatedAt: new Date().toISOString(),
120
+ };
121
+ store.set(binding);
122
+
123
+ const found = store.get('ctx-1', 'codex');
124
+ expect(found).not.toBeNull();
125
+ expect(found!.sessionId).toBe('ses-1');
126
+ expect(found!.activeTaskId).toBe('task-1');
127
+ });
128
+
129
+ it('should scope bindings by provider', () => {
130
+ store.set({
131
+ provider: 'codex',
132
+ contextId: 'ctx-1',
133
+ sessionId: 'ses-1',
134
+ createdAt: new Date().toISOString(),
135
+ updatedAt: new Date().toISOString(),
136
+ });
137
+
138
+ expect(store.get('ctx-1', 'codex')).not.toBeNull();
139
+ expect(store.get('ctx-1', 'claude')).toBeNull();
140
+ });
141
+
142
+ it('should delete a binding', () => {
143
+ store.set({
144
+ provider: 'codex',
145
+ contextId: 'ctx-1',
146
+ sessionId: 'ses-1',
147
+ createdAt: new Date().toISOString(),
148
+ updatedAt: new Date().toISOString(),
149
+ });
150
+
151
+ expect(store.delete('ctx-1', 'codex')).toBe(true);
152
+ expect(store.get('ctx-1', 'codex')).toBeNull();
153
+ });
154
+
155
+ it('should return false when deleting non-existent binding', () => {
156
+ expect(store.delete('nope', 'codex')).toBe(false);
157
+ });
158
+
159
+ it('should clear active task', () => {
160
+ store.set({
161
+ provider: 'codex',
162
+ contextId: 'ctx-1',
163
+ sessionId: 'ses-1',
164
+ activeTaskId: 'task-1',
165
+ activeTaskState: 'input-required',
166
+ createdAt: new Date().toISOString(),
167
+ updatedAt: new Date().toISOString(),
168
+ });
169
+
170
+ store.clearActiveTask('ctx-1', 'codex');
171
+
172
+ const found = store.get('ctx-1', 'codex');
173
+ expect(found!.activeTaskId).toBeUndefined();
174
+ expect(found!.activeTaskState).toBeUndefined();
175
+ });
176
+
177
+ it('should report correct store size', () => {
178
+ expect(store.size).toBe(0);
179
+ store.set({
180
+ provider: 'codex',
181
+ contextId: 'ctx-a',
182
+ sessionId: 's1',
183
+ createdAt: new Date().toISOString(),
184
+ updatedAt: new Date().toISOString(),
185
+ });
186
+ store.set({
187
+ provider: 'codex',
188
+ contextId: 'ctx-b',
189
+ sessionId: 's2',
190
+ createdAt: new Date().toISOString(),
191
+ updatedAt: new Date().toISOString(),
192
+ });
193
+ expect(store.size).toBe(2);
194
+ });
195
+ });
196
+
197
+ describe('TTL cleanup', () => {
198
+ it('should return null for expired bindings', () => {
199
+ store.set({
200
+ provider: 'codex',
201
+ contextId: 'ctx-1',
202
+ sessionId: 'ses-1',
203
+ createdAt: new Date(Date.now() - 10_000).toISOString(),
204
+ updatedAt: new Date(Date.now() - 10_000).toISOString(),
205
+ expiresAt: new Date(Date.now() - 1_000).toISOString(), // expired 1s ago
206
+ });
207
+
208
+ expect(store.get('ctx-1', 'codex')).toBeNull();
209
+ expect(store.size).toBe(0); // should be removed from cache
210
+ });
211
+
212
+ it('should not expire bindings without expiresAt', () => {
213
+ store.set({
214
+ provider: 'codex',
215
+ contextId: 'ctx-1',
216
+ sessionId: 'ses-1',
217
+ createdAt: new Date().toISOString(),
218
+ updatedAt: new Date().toISOString(),
219
+ });
220
+
221
+ expect(store.get('ctx-1', 'codex')).not.toBeNull();
222
+ });
223
+
224
+ it('should clean up expired entries', () => {
225
+ store.set({
226
+ provider: 'codex',
227
+ contextId: 'ctx-expired',
228
+ sessionId: 'ses-expired',
229
+ createdAt: new Date().toISOString(),
230
+ updatedAt: new Date().toISOString(),
231
+ expiresAt: new Date(Date.now() - 1_000).toISOString(),
232
+ });
233
+ store.set({
234
+ provider: 'codex',
235
+ contextId: 'ctx-valid',
236
+ sessionId: 'ses-valid',
237
+ createdAt: new Date().toISOString(),
238
+ updatedAt: new Date().toISOString(),
239
+ });
240
+
241
+ const removed = store.cleanup();
242
+ expect(removed).toBe(1);
243
+ expect(store.get('ctx-expired', 'codex')).toBeNull();
244
+ expect(store.get('ctx-valid', 'codex')).not.toBeNull();
245
+ });
246
+ });
247
+
248
+ describe('file persistence', () => {
249
+ let persistDir: string;
250
+
251
+ beforeEach(() => {
252
+ persistDir = createTestStoreDir();
253
+ });
254
+
255
+ afterEach(() => {
256
+ try { rmSync(persistDir, { recursive: true, force: true }); } catch { /* ignore */ }
257
+ });
258
+
259
+ it('should load bindings from file on startup', () => {
260
+ const storePath = join(persistDir, 'sessions.json');
261
+ const data = {
262
+ version: 1,
263
+ bindings: [
264
+ {
265
+ provider: 'codex',
266
+ contextId: 'ctx-from-file',
267
+ sessionId: 'ses-file',
268
+ createdAt: new Date().toISOString(),
269
+ updatedAt: new Date().toISOString(),
270
+ },
271
+ ],
272
+ };
273
+ writeFileSync(storePath, JSON.stringify(data));
274
+
275
+ const loadedStore = new SessionBindingStore({ storePath, persist: true, cleanupIntervalMs: 100_000 });
276
+ expect(loadedStore.get('ctx-from-file', 'codex')).not.toBeNull();
277
+ expect(loadedStore.get('ctx-from-file', 'codex')!.sessionId).toBe('ses-file');
278
+ loadedStore.shutdown();
279
+ });
280
+
281
+ it('should recover from corrupted JSON by starting fresh', () => {
282
+ const storePath = join(persistDir, 'sessions.json');
283
+ writeFileSync(storePath, '{ not valid json }');
284
+
285
+ const store = new SessionBindingStore({ storePath, persist: true });
286
+ expect(store.size).toBe(0);
287
+ store.shutdown();
288
+ });
289
+
290
+ it('should survive shutdown and reload', () => {
291
+ const storePath = join(persistDir, 'sessions.json');
292
+
293
+ const store1 = new SessionBindingStore({ storePath, persist: true, cleanupIntervalMs: 100_000 });
294
+ store1.set({
295
+ provider: 'codex',
296
+ contextId: 'ctx-restart',
297
+ sessionId: 'ses-restart',
298
+ createdAt: new Date().toISOString(),
299
+ updatedAt: new Date().toISOString(),
300
+ });
301
+ store1.shutdown();
302
+
303
+ // Simulate restart: new store instance loads same file
304
+ const store2 = new SessionBindingStore({ storePath, persist: true, cleanupIntervalMs: 100_000 });
305
+ const binding = store2.get('ctx-restart', 'codex');
306
+ expect(binding).not.toBeNull();
307
+ expect(binding!.sessionId).toBe('ses-restart');
308
+ store2.shutdown();
309
+ });
310
+
311
+ it('should skip expired bindings on load', () => {
312
+ const storePath = join(persistDir, 'sessions.json');
313
+ const data = {
314
+ version: 1,
315
+ bindings: [
316
+ {
317
+ provider: 'codex',
318
+ contextId: 'ctx-expired',
319
+ sessionId: 'ses-expired',
320
+ createdAt: new Date().toISOString(),
321
+ updatedAt: new Date().toISOString(),
322
+ expiresAt: new Date(Date.now() - 1_000).toISOString(),
323
+ },
324
+ {
325
+ provider: 'codex',
326
+ contextId: 'ctx-valid',
327
+ sessionId: 'ses-valid',
328
+ createdAt: new Date().toISOString(),
329
+ updatedAt: new Date().toISOString(),
330
+ },
331
+ ],
332
+ };
333
+ writeFileSync(storePath, JSON.stringify(data));
334
+
335
+ const store = new SessionBindingStore({ storePath, persist: true });
336
+ expect(store.get('ctx-expired', 'codex')).toBeNull();
337
+ expect(store.get('ctx-valid', 'codex')).not.toBeNull();
338
+ store.shutdown();
339
+ });
340
+ });
341
+
342
+ describe('generateContextId', () => {
343
+ it('should generate unique UUIDs', () => {
344
+ const id1 = generateContextId();
345
+ const id2 = generateContextId();
346
+ expect(id1).not.toBe(id2);
347
+ expect(id1.length).toBeGreaterThan(0);
348
+ });
349
+ });
350
+ });
351
+
352
+ // ─── Client Argument Tests ──────────────────────────────────────────────────
353
+
354
+ describe('CodexClient session args', () => {
355
+ it('should configure client for new session', () => {
356
+ const client = new CodexClient({
357
+ cliPath: 'codex',
358
+ workdir: '/tmp/test',
359
+ timeout: 1000,
360
+ });
361
+ expect(client).toBeDefined();
362
+ });
363
+
364
+ it('should configure client for resume', () => {
365
+ const client = new CodexClient({
366
+ cliPath: 'codex',
367
+ workdir: '/tmp/test',
368
+ timeout: 1000,
369
+ });
370
+ expect(client).toBeDefined();
371
+ });
372
+
373
+ it('should reject oversized prompts', async () => {
374
+ const client = new CodexClient({
375
+ cliPath: 'codex',
376
+ workdir: '/tmp/test',
377
+ timeout: 1000,
378
+ });
379
+ const bigPrompt = 'x'.repeat(200_000);
380
+ await expect(client.execute(bigPrompt)).rejects.toThrow('Prompt too large');
381
+ });
382
+ });
383
+
384
+ describe('ClaudeClient session args', () => {
385
+ it('should configure client for new session', () => {
386
+ const client = new ClaudeClient({
387
+ cliPath: 'claude',
388
+ workdir: '/tmp/test',
389
+ timeout: 1000,
390
+ });
391
+ expect(client).toBeDefined();
392
+ });
393
+
394
+ it('should configure client for resume', () => {
395
+ const client = new ClaudeClient({
396
+ cliPath: 'claude',
397
+ workdir: '/tmp/test',
398
+ timeout: 1000,
399
+ });
400
+ expect(client).toBeDefined();
401
+ });
402
+ });
403
+
404
+ describe('OpenCodeClient session args', () => {
405
+ it('should configure client for new session', () => {
406
+ const client = new OpenCodeClient({
407
+ cliPath: 'opencode',
408
+ workdir: '/tmp/test',
409
+ timeout: 1000,
410
+ });
411
+ expect(client).toBeDefined();
412
+ });
413
+
414
+ it('should configure client for resume', () => {
415
+ const client = new OpenCodeClient({
416
+ cliPath: 'opencode',
417
+ workdir: '/tmp/test',
418
+ timeout: 1000,
419
+ });
420
+ expect(client).toBeDefined();
421
+ });
422
+ });
423
+
424
+ // ─── Executor Tests ─────────────────────────────────────────────────────────
425
+
56
426
  describe('OpenCodeExecutor', () => {
427
+ let store: SessionBindingStore;
428
+
429
+ beforeEach(() => {
430
+ store = createStore();
431
+ });
432
+
433
+ afterEach(() => {
434
+ store.shutdown();
435
+ });
436
+
57
437
  it('should create an executor with valid config', () => {
58
438
  const config = createMockConfig();
59
- const executor = new OpenCodeExecutor(config);
439
+ const executor = new OpenCodeExecutor(config, store);
60
440
  expect(executor).toBeDefined();
61
441
  });
62
442
 
63
443
  it('should have initialize method', () => {
64
444
  const config = createMockConfig();
65
- const executor = new OpenCodeExecutor(config);
445
+ const executor = new OpenCodeExecutor(config, store);
66
446
  expect(typeof executor.initialize).toBe('function');
67
447
  });
68
448
 
69
449
  it('should have shutdown method', () => {
70
450
  const config = createMockConfig();
71
- const executor = new OpenCodeExecutor(config);
451
+ const executor = new OpenCodeExecutor(config, store);
72
452
  expect(typeof executor.shutdown).toBe('function');
73
453
  });
74
454
 
75
455
  it('should have execute method', () => {
76
456
  const config = createMockConfig();
77
- const executor = new OpenCodeExecutor(config);
457
+ const executor = new OpenCodeExecutor(config, store);
78
458
  expect(typeof executor.execute).toBe('function');
79
459
  });
80
460
 
81
461
  it('should have cancelTask method', () => {
82
462
  const config = createMockConfig();
83
- const executor = new OpenCodeExecutor(config);
463
+ const executor = new OpenCodeExecutor(config, store);
84
464
  expect(typeof executor.cancelTask).toBe('function');
85
465
  });
86
466
 
87
- it('should not be initialized until initialize is called', () => {
467
+ it('should cleanup on shutdown', async () => {
88
468
  const config = createMockConfig();
89
- const executor = new OpenCodeExecutor(config);
469
+ const executor = new OpenCodeExecutor(config, store);
470
+ await executor.shutdown();
90
471
  // @ts-expect-error testing private field
91
472
  expect(executor.initialized).toBe(false);
473
+ // @ts-expect-error testing private field
474
+ expect(executor.client).toBeNull();
92
475
  });
93
476
 
94
477
  it('should handle config with empty model', () => {
95
478
  const config = createMockConfig({
96
479
  opencode: { model: '' },
97
480
  });
98
- const executor = new OpenCodeExecutor(config);
481
+ const executor = new OpenCodeExecutor(config, store);
99
482
  expect(executor).toBeDefined();
100
483
  });
101
484
 
@@ -106,36 +489,79 @@ describe('OpenCodeExecutor', () => {
106
489
  systemPromptMode: 'replace',
107
490
  },
108
491
  });
109
- const executor = new OpenCodeExecutor(config);
492
+ const executor = new OpenCodeExecutor(config, store);
493
+ expect(executor).toBeDefined();
494
+ });
495
+ });
496
+
497
+ describe('CodexExecutor', () => {
498
+ let store: SessionBindingStore;
499
+
500
+ beforeEach(() => {
501
+ store = createStore();
502
+ });
503
+
504
+ afterEach(() => {
505
+ store.shutdown();
506
+ });
507
+
508
+ it('should create an executor with valid config', () => {
509
+ const config = createMockConfig({ provider: 'codex' });
510
+ const executor = new CodexExecutor(config, store);
110
511
  expect(executor).toBeDefined();
111
512
  });
112
513
 
514
+ it('should have required methods', () => {
515
+ const config = createMockConfig({ provider: 'codex' });
516
+ const executor = new CodexExecutor(config, store);
517
+ expect(typeof executor.initialize).toBe('function');
518
+ expect(typeof executor.execute).toBe('function');
519
+ expect(typeof executor.cancelTask).toBe('function');
520
+ expect(typeof executor.shutdown).toBe('function');
521
+ });
522
+
113
523
  it('should cleanup on shutdown', async () => {
114
- const config = createMockConfig();
115
- const executor = new OpenCodeExecutor(config);
524
+ const config = createMockConfig({ provider: 'codex' });
525
+ const executor = new CodexExecutor(config, store);
116
526
  await executor.shutdown();
117
- // @ts-expect-error testing private field
118
- expect(executor.initialized).toBe(false);
119
- // @ts-expect-error testing private field
120
- expect(executor.client).toBeNull();
121
527
  });
528
+ });
122
529
 
123
- it('should allow double initialization without error', async () => {
124
- const config = createMockConfig({
125
- features: {
126
- autoApprovePermissions: false,
127
- autoAnswerQuestions: false,
128
- },
129
- });
130
- const executor = new OpenCodeExecutor(config);
530
+ describe('ClaudeExecutor', () => {
531
+ let store: SessionBindingStore;
532
+
533
+ beforeEach(() => {
534
+ store = createStore();
535
+ });
536
+
537
+ afterEach(() => {
538
+ store.shutdown();
539
+ });
131
540
 
132
- // First call will fail because no server, but should set initialized=false
133
- try { await executor.initialize(); } catch { /* no server */ }
134
- // Second call should be safe
135
- try { await executor.initialize(); } catch { /* no server */ }
541
+ it('should create an executor with valid config', () => {
542
+ const config = createMockConfig({ provider: 'claude' });
543
+ const executor = new ClaudeExecutor(config, store);
544
+ expect(executor).toBeDefined();
545
+ });
546
+
547
+ it('should have required methods', () => {
548
+ const config = createMockConfig({ provider: 'claude' });
549
+ const executor = new ClaudeExecutor(config, store);
550
+ expect(typeof executor.initialize).toBe('function');
551
+ expect(typeof executor.execute).toBe('function');
552
+ expect(typeof executor.cancelTask).toBe('function');
553
+ expect(typeof executor.shutdown).toBe('function');
554
+ });
555
+
556
+ it('should cleanup on shutdown', async () => {
557
+ const config = createMockConfig({ provider: 'claude' });
558
+ const executor = new ClaudeExecutor(config, store);
559
+ await executor.shutdown();
136
560
  });
137
561
  });
138
562
 
563
+ // ─── Config Tests ───────────────────────────────────────────────────────────
564
+
139
565
  describe('Config resolution', () => {
140
566
  it('should resolve defaults when no config file provided', () => {
141
567
  const { resolveConfig } = require('../src/config/loader');
@@ -143,6 +569,8 @@ describe('Config resolution', () => {
143
569
  expect(config.provider).toBe('opencode');
144
570
  expect(config.agentCard.name).toBeDefined();
145
571
  expect(config.server?.port).toBeDefined();
572
+ expect(config.session?.persist).toBe(true);
573
+ expect(config.session?.reuseByContext).toBe(true);
146
574
  });
147
575
 
148
576
  it('should merge CLI overrides into defaults', () => {
@@ -157,3 +585,175 @@ describe('Config resolution', () => {
157
585
  expect(config.agentCard.name).toBe('Custom Agent');
158
586
  });
159
587
  });
588
+
589
+ // ─── Multi-Turn Scenario Tests ──────────────────────────────────────────────
590
+
591
+ describe('Multi-turn session reuse', () => {
592
+ let persistDir: string;
593
+
594
+ beforeEach(() => {
595
+ persistDir = createTestStoreDir();
596
+ });
597
+
598
+ afterEach(() => {
599
+ try { rmSync(persistDir, { recursive: true, force: true }); } catch { /* ignore */ }
600
+ });
601
+
602
+ it('should reuse session for same contextId across two turns', () => {
603
+ const storePath = join(persistDir, 'sessions.json');
604
+ const store = new SessionBindingStore({ storePath, persist: true, cleanupIntervalMs: 100_000 });
605
+
606
+ const contextId = 'multi-turn-ctx';
607
+ const taskId1 = 'task-turn-1';
608
+ const taskId2 = 'task-turn-2';
609
+
610
+ // Turn 1: create binding
611
+ store.set({
612
+ provider: 'codex',
613
+ contextId,
614
+ sessionId: 'codex-ses-abc',
615
+ activeTaskId: taskId1,
616
+ createdAt: new Date().toISOString(),
617
+ updatedAt: new Date().toISOString(),
618
+ });
619
+
620
+ // Turn 2: lookup binding
621
+ const binding = store.get(contextId, 'codex');
622
+ expect(binding).not.toBeNull();
623
+ expect(binding!.sessionId).toBe('codex-ses-abc');
624
+
625
+ // Update for turn 2
626
+ store.set({
627
+ provider: 'codex',
628
+ contextId,
629
+ sessionId: 'codex-ses-abc',
630
+ activeTaskId: taskId2,
631
+ createdAt: binding!.createdAt,
632
+ updatedAt: new Date().toISOString(),
633
+ });
634
+
635
+ store.shutdown();
636
+
637
+ // Restart: should persist across process restarts
638
+ const store2 = new SessionBindingStore({ storePath, persist: true, cleanupIntervalMs: 100_000 });
639
+ const binding2 = store2.get(contextId, 'codex');
640
+ expect(binding2).not.toBeNull();
641
+ expect(binding2!.sessionId).toBe('codex-ses-abc');
642
+ store2.shutdown();
643
+ });
644
+
645
+ it('should create separate sessions for different contextIds', () => {
646
+ const store = createStore();
647
+ const ctxA = 'context-a';
648
+ const ctxB = 'context-b';
649
+
650
+ store.set({
651
+ provider: 'codex',
652
+ contextId: ctxA,
653
+ sessionId: 'ses-a',
654
+ createdAt: new Date().toISOString(),
655
+ updatedAt: new Date().toISOString(),
656
+ });
657
+
658
+ store.set({
659
+ provider: 'codex',
660
+ contextId: ctxB,
661
+ sessionId: 'ses-b',
662
+ createdAt: new Date().toISOString(),
663
+ updatedAt: new Date().toISOString(),
664
+ });
665
+
666
+ expect(store.get(ctxA, 'codex')!.sessionId).toBe('ses-a');
667
+ expect(store.get(ctxB, 'codex')!.sessionId).toBe('ses-b');
668
+ expect(store.get(ctxA, 'codex')!.sessionId).not.toBe(
669
+ store.get(ctxB, 'codex')!.sessionId,
670
+ );
671
+ store.shutdown();
672
+ });
673
+
674
+ it('should scope sessions by provider', () => {
675
+ const store = createStore();
676
+ const ctx = 'shared-ctx';
677
+
678
+ store.set({
679
+ provider: 'codex',
680
+ contextId: ctx,
681
+ sessionId: 'ses-codex',
682
+ createdAt: new Date().toISOString(),
683
+ updatedAt: new Date().toISOString(),
684
+ });
685
+
686
+ store.set({
687
+ provider: 'claude',
688
+ contextId: ctx,
689
+ sessionId: 'ses-claude',
690
+ createdAt: new Date().toISOString(),
691
+ updatedAt: new Date().toISOString(),
692
+ });
693
+
694
+ expect(store.get(ctx, 'codex')!.sessionId).toBe('ses-codex');
695
+ expect(store.get(ctx, 'claude')!.sessionId).toBe('ses-claude');
696
+ store.shutdown();
697
+ });
698
+
699
+ it('should clear active task without removing session', () => {
700
+ const store = createStore();
701
+ const ctx = 'ctx-task-test';
702
+
703
+ store.set({
704
+ provider: 'codex',
705
+ contextId: ctx,
706
+ sessionId: 'ses-1',
707
+ activeTaskId: 'task-1',
708
+ activeTaskState: 'input-required',
709
+ createdAt: new Date().toISOString(),
710
+ updatedAt: new Date().toISOString(),
711
+ });
712
+
713
+ store.clearActiveTask(ctx, 'codex');
714
+
715
+ const binding = store.get(ctx, 'codex');
716
+ expect(binding).not.toBeNull();
717
+ expect(binding!.sessionId).toBe('ses-1'); // session preserved
718
+ expect(binding!.activeTaskId).toBeUndefined(); // task cleared
719
+
720
+ store.shutdown();
721
+ });
722
+
723
+ it('should not reuse session when reuseByContext is false', () => {
724
+ const store = createStore();
725
+ const ctx = 'ctx-no-reuse';
726
+
727
+ // Simulate what a non-reusing server would do:
728
+ // Set an initial binding, but don't look it up
729
+ store.set({
730
+ provider: 'codex',
731
+ contextId: ctx,
732
+ sessionId: 'ses-original',
733
+ createdAt: new Date().toISOString(),
734
+ updatedAt: new Date().toISOString(),
735
+ });
736
+
737
+ // When reuseByContext=false, the executor skips the get() call
738
+ // Verify the original binding still exists (not overwritten)
739
+ const binding = store.get(ctx, 'codex');
740
+ expect(binding).not.toBeNull();
741
+ expect(binding!.sessionId).toBe('ses-original');
742
+
743
+ // But executor would call set() with new sessionId without looking up
744
+ store.set({
745
+ provider: 'codex',
746
+ contextId: ctx,
747
+ sessionId: 'ses-new',
748
+ createdAt: new Date().toISOString(),
749
+ updatedAt: new Date().toISOString(),
750
+ });
751
+
752
+ // The new binding overwrites the old one — but this is fine because
753
+ // with reuseByContext=false, the executor never reads the old binding
754
+ const newBinding = store.get(ctx, 'codex');
755
+ expect(newBinding!.sessionId).toBe('ses-new');
756
+
757
+ store.shutdown();
758
+ });
759
+ });