@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.
- package/.turbo/turbo-build.log +34 -32
- package/CHANGELOG.md +26 -0
- package/README.md +85 -2
- package/__tests__/opencode-executor.test.ts +631 -31
- package/__tests__/session-store-path.test.ts +40 -0
- package/dist/{chunk-NQIDRU47.mjs → chunk-54SKDK3D.mjs} +78 -32
- package/dist/chunk-54SKDK3D.mjs.map +1 -0
- package/dist/chunk-BSNQOP2W.mjs +350 -0
- package/dist/chunk-BSNQOP2W.mjs.map +1 -0
- package/dist/chunk-DRFFQJII.mjs +195 -0
- package/dist/chunk-DRFFQJII.mjs.map +1 -0
- package/dist/chunk-URJDRXJW.mjs +378 -0
- package/dist/chunk-URJDRXJW.mjs.map +1 -0
- package/dist/{chunk-G7AGL2QA.mjs → chunk-WZB7FBSX.mjs} +23 -5
- package/dist/chunk-WZB7FBSX.mjs.map +1 -0
- package/dist/{chunk-LXL47XMZ.mjs → chunk-YGUPSPZO.mjs} +9 -1
- package/dist/chunk-YGUPSPZO.mjs.map +1 -0
- package/dist/{chunk-35NFMGMS.mjs → chunk-ZP3JNJQJ.mjs} +3 -3
- package/dist/chunk-ZP3JNJQJ.mjs.map +1 -0
- package/dist/cli.js +769 -156
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +7 -6
- package/dist/cli.mjs.map +1 -1
- package/dist/executor-LLE5VEFJ.mjs +12 -0
- package/dist/executor-LO7P3KUW.mjs +12 -0
- package/dist/{executor-RVBGAWUF.mjs → executor-OOLGGXHS.mjs} +4 -3
- package/dist/{executors-QIIKBUMJ.mjs → executors-AQHOPGSR.mjs} +2 -2
- package/dist/index.d.mts +92 -21
- package/dist/index.d.ts +92 -21
- package/dist/index.js +731 -123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +15 -7
- package/package.json +11 -4
- package/runtime/cli-path.cjs +3 -0
- package/runtime/cli-path.d.ts +1 -0
- package/runtime/cli-path.mjs +5 -0
- package/src/config/defaults.ts +3 -1
- package/src/config/types.ts +8 -3
- package/src/executors/claude/client.ts +44 -14
- package/src/executors/claude/executor.ts +65 -30
- package/src/executors/codex/client.ts +239 -16
- package/src/executors/codex/executor.ts +70 -37
- package/src/executors/events.ts +15 -1
- package/src/executors/index.ts +13 -6
- package/src/executors/opencode/client.ts +192 -18
- package/src/executors/opencode/executor.ts +65 -30
- package/src/index.ts +8 -0
- package/src/local-state.ts +53 -0
- package/src/server/agent-card.ts +1 -0
- package/src/server/index.ts +22 -7
- package/src/session-store.ts +262 -0
- package/dist/chunk-35NFMGMS.mjs.map +0 -1
- package/dist/chunk-G7AGL2QA.mjs.map +0 -1
- package/dist/chunk-LXL47XMZ.mjs.map +0 -1
- package/dist/chunk-NQIDRU47.mjs.map +0 -1
- package/dist/chunk-VSZ3DACI.mjs +0 -179
- package/dist/chunk-VSZ3DACI.mjs.map +0 -1
- package/dist/chunk-WNCDOYZS.mjs +0 -187
- package/dist/chunk-WNCDOYZS.mjs.map +0 -1
- package/dist/executor-OWPVDUXH.mjs +0 -11
- package/dist/executor-XWHWUVQ3.mjs +0 -11
- /package/dist/{executor-OWPVDUXH.mjs.map → executor-LLE5VEFJ.mjs.map} +0 -0
- /package/dist/{executor-RVBGAWUF.mjs.map → executor-LO7P3KUW.mjs.map} +0 -0
- /package/dist/{executor-XWHWUVQ3.mjs.map → executor-OOLGGXHS.mjs.map} +0 -0
- /package/dist/{executors-QIIKBUMJ.mjs.map → executors-AQHOPGSR.mjs.map} +0 -0
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
2
|
+
* Tests for CLI A2A multi-turn conversation support.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
530
|
+
describe('ClaudeExecutor', () => {
|
|
531
|
+
let store: SessionBindingStore;
|
|
532
|
+
|
|
533
|
+
beforeEach(() => {
|
|
534
|
+
store = createStore();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
afterEach(() => {
|
|
538
|
+
store.shutdown();
|
|
539
|
+
});
|
|
131
540
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
});
|