@cleocode/adapters 2026.4.47 → 2026.4.49
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/dist/cant-context.d.ts +132 -1
- package/dist/cant-context.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19765 -375
- package/dist/index.js.map +4 -4
- package/dist/providers/claude-code/adapter.d.ts +12 -6
- package/dist/providers/claude-code/adapter.d.ts.map +1 -1
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/claude-code/spawn.d.ts.map +1 -1
- package/dist/providers/claude-sdk/index.d.ts +18 -0
- package/dist/providers/claude-sdk/index.d.ts.map +1 -0
- package/dist/providers/claude-sdk/mcp-registry.d.ts +40 -0
- package/dist/providers/claude-sdk/mcp-registry.d.ts.map +1 -0
- package/dist/providers/claude-sdk/session-store.d.ts +78 -0
- package/dist/providers/claude-sdk/session-store.d.ts.map +1 -0
- package/dist/providers/claude-sdk/spawn.d.ts +79 -0
- package/dist/providers/claude-sdk/spawn.d.ts.map +1 -0
- package/dist/providers/claude-sdk/tool-bridge.d.ts +38 -0
- package/dist/providers/claude-sdk/tool-bridge.d.ts.map +1 -0
- package/dist/providers/openai-sdk/adapter.d.ts +77 -0
- package/dist/providers/openai-sdk/adapter.d.ts.map +1 -0
- package/dist/providers/openai-sdk/guardrails.d.ts +67 -0
- package/dist/providers/openai-sdk/guardrails.d.ts.map +1 -0
- package/dist/providers/openai-sdk/handoff.d.ts +94 -0
- package/dist/providers/openai-sdk/handoff.d.ts.map +1 -0
- package/dist/providers/openai-sdk/index.d.ts +39 -0
- package/dist/providers/openai-sdk/index.d.ts.map +1 -0
- package/dist/providers/openai-sdk/install.d.ts +61 -0
- package/dist/providers/openai-sdk/install.d.ts.map +1 -0
- package/dist/providers/openai-sdk/spawn.d.ts +146 -0
- package/dist/providers/openai-sdk/spawn.d.ts.map +1 -0
- package/dist/providers/openai-sdk/tracing.d.ts +89 -0
- package/dist/providers/openai-sdk/tracing.d.ts.map +1 -0
- package/dist/providers/shared/conduit-trace-writer.d.ts +72 -0
- package/dist/providers/shared/conduit-trace-writer.d.ts.map +1 -0
- package/dist/providers/shared/sdk-result-mapper.d.ts +51 -0
- package/dist/providers/shared/sdk-result-mapper.d.ts.map +1 -0
- package/package.json +5 -3
- package/src/cant-context.ts +397 -3
- package/src/index.ts +24 -2
- package/src/providers/claude-code/adapter.ts +41 -4
- package/src/providers/claude-code/hooks.ts +7 -1
- package/src/providers/claude-code/spawn.ts +5 -1
- package/src/providers/claude-sdk/__tests__/spawn.test.ts +448 -0
- package/src/providers/claude-sdk/index.ts +18 -0
- package/src/providers/claude-sdk/mcp-registry.ts +96 -0
- package/src/providers/claude-sdk/session-store.ts +103 -0
- package/src/providers/claude-sdk/spawn.ts +242 -0
- package/src/providers/claude-sdk/tool-bridge.ts +51 -0
- package/src/providers/openai-sdk/__tests__/openai-sdk-spawn.test.ts +716 -0
- package/src/providers/openai-sdk/adapter.ts +138 -0
- package/src/providers/openai-sdk/guardrails.ts +158 -0
- package/src/providers/openai-sdk/handoff.ts +187 -0
- package/src/providers/openai-sdk/index.ts +55 -0
- package/src/providers/openai-sdk/install.ts +135 -0
- package/src/providers/openai-sdk/manifest.json +45 -0
- package/src/providers/openai-sdk/spawn.ts +300 -0
- package/src/providers/openai-sdk/tracing.ts +175 -0
- package/src/providers/shared/conduit-trace-writer.ts +101 -0
- package/src/providers/shared/sdk-result-mapper.ts +83 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ClaudeSDKSpawnProvider
|
|
3
|
+
*
|
|
4
|
+
* The SDK `query()` function is mocked so tests run without a real
|
|
5
|
+
* ANTHROPIC_API_KEY or network connection.
|
|
6
|
+
*
|
|
7
|
+
* @task T581
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { ClaudeSDKSpawnProvider } from '../spawn.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Mock the SDK module
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Minimal SDK message iterator builder for tests. */
|
|
18
|
+
function makeQueryIterator(messages: Array<Record<string, unknown>>) {
|
|
19
|
+
return {
|
|
20
|
+
[Symbol.asyncIterator]: async function* () {
|
|
21
|
+
for (const msg of messages) {
|
|
22
|
+
yield msg;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
|
29
|
+
query: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock CANT enrichment so tests don't need the cleo CLI.
|
|
33
|
+
vi.mock('../../../cant-context.js', () => ({
|
|
34
|
+
buildCantEnrichedPrompt: vi.fn(({ basePrompt }: { basePrompt: string }) =>
|
|
35
|
+
Promise.resolve(basePrompt),
|
|
36
|
+
),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async function getQueryMock() {
|
|
44
|
+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
45
|
+
return query as ReturnType<typeof vi.fn>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Tests
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe('ClaudeSDKSpawnProvider', () => {
|
|
53
|
+
let provider: ClaudeSDKSpawnProvider;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
provider = new ClaudeSDKSpawnProvider();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
// canSpawn
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('canSpawn()', () => {
|
|
68
|
+
it('returns true when ANTHROPIC_API_KEY is set', async () => {
|
|
69
|
+
process.env.ANTHROPIC_API_KEY = 'test-key';
|
|
70
|
+
expect(await provider.canSpawn()).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns false when ANTHROPIC_API_KEY is absent', async () => {
|
|
74
|
+
const saved = process.env.ANTHROPIC_API_KEY;
|
|
75
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
76
|
+
try {
|
|
77
|
+
expect(await provider.canSpawn()).toBe(false);
|
|
78
|
+
} finally {
|
|
79
|
+
if (saved !== undefined) {
|
|
80
|
+
process.env.ANTHROPIC_API_KEY = saved;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// listRunning / terminate
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('listRunning()', () => {
|
|
91
|
+
it('returns empty array when no spawns are active', async () => {
|
|
92
|
+
expect(await provider.listRunning()).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('terminate()', () => {
|
|
97
|
+
it('is a no-op for unknown instance IDs', async () => {
|
|
98
|
+
await expect(provider.terminate('nonexistent')).resolves.toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// spawn — success path
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
describe('spawn() — success', () => {
|
|
107
|
+
it('returns completed status with aggregated output', async () => {
|
|
108
|
+
const queryMock = await getQueryMock();
|
|
109
|
+
queryMock.mockReturnValue(
|
|
110
|
+
makeQueryIterator([
|
|
111
|
+
{
|
|
112
|
+
type: 'system',
|
|
113
|
+
subtype: 'init',
|
|
114
|
+
session_id: 'sess-abc',
|
|
115
|
+
tools: [],
|
|
116
|
+
mcp_servers: [],
|
|
117
|
+
model: 'claude-sonnet-4-5',
|
|
118
|
+
permissionMode: 'bypassPermissions',
|
|
119
|
+
cwd: '/tmp',
|
|
120
|
+
slash_commands: [],
|
|
121
|
+
output_style: 'auto',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: 'assistant',
|
|
125
|
+
session_id: 'sess-abc',
|
|
126
|
+
message: {
|
|
127
|
+
content: [
|
|
128
|
+
{ type: 'text', text: 'Hello from' },
|
|
129
|
+
{ type: 'text', text: ' the SDK.' },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
type: 'result',
|
|
135
|
+
subtype: 'success',
|
|
136
|
+
session_id: 'sess-abc',
|
|
137
|
+
result: 'Done.',
|
|
138
|
+
is_error: false,
|
|
139
|
+
duration_ms: 100,
|
|
140
|
+
duration_api_ms: 80,
|
|
141
|
+
num_turns: 1,
|
|
142
|
+
total_cost_usd: 0.001,
|
|
143
|
+
usage: {},
|
|
144
|
+
modelUsage: {},
|
|
145
|
+
permission_denials: [],
|
|
146
|
+
},
|
|
147
|
+
]),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const result = await provider.spawn({
|
|
151
|
+
taskId: 'T001',
|
|
152
|
+
prompt: 'Do something.',
|
|
153
|
+
workingDirectory: '/tmp',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.status).toBe('completed');
|
|
157
|
+
expect(result.providerId).toBe('claude-sdk');
|
|
158
|
+
expect(result.taskId).toBe('T001');
|
|
159
|
+
expect(result.output).toContain('Hello from');
|
|
160
|
+
expect(result.output).toContain('the SDK.');
|
|
161
|
+
expect(result.output).toContain('Done.');
|
|
162
|
+
expect(result.exitCode).toBe(0);
|
|
163
|
+
expect(result.startTime).toBeTruthy();
|
|
164
|
+
expect(result.endTime).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('passes allowedTools from context options', async () => {
|
|
168
|
+
const queryMock = await getQueryMock();
|
|
169
|
+
queryMock.mockReturnValue(
|
|
170
|
+
makeQueryIterator([
|
|
171
|
+
{
|
|
172
|
+
type: 'result',
|
|
173
|
+
subtype: 'success',
|
|
174
|
+
session_id: 'sess-xyz',
|
|
175
|
+
result: '',
|
|
176
|
+
is_error: false,
|
|
177
|
+
duration_ms: 10,
|
|
178
|
+
duration_api_ms: 8,
|
|
179
|
+
num_turns: 1,
|
|
180
|
+
total_cost_usd: 0,
|
|
181
|
+
usage: {},
|
|
182
|
+
modelUsage: {},
|
|
183
|
+
permission_denials: [],
|
|
184
|
+
},
|
|
185
|
+
]),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await provider.spawn({
|
|
189
|
+
taskId: 'T002',
|
|
190
|
+
prompt: 'Read only.',
|
|
191
|
+
options: { toolAllowlist: ['Read', 'Grep'] },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(queryMock).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({
|
|
196
|
+
options: expect.objectContaining({
|
|
197
|
+
allowedTools: ['Read', 'Grep'],
|
|
198
|
+
}),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('uses default tools when no toolAllowlist provided', async () => {
|
|
204
|
+
const queryMock = await getQueryMock();
|
|
205
|
+
queryMock.mockReturnValue(
|
|
206
|
+
makeQueryIterator([
|
|
207
|
+
{
|
|
208
|
+
type: 'result',
|
|
209
|
+
subtype: 'success',
|
|
210
|
+
session_id: 'sess-xyz',
|
|
211
|
+
result: '',
|
|
212
|
+
is_error: false,
|
|
213
|
+
duration_ms: 10,
|
|
214
|
+
duration_api_ms: 8,
|
|
215
|
+
num_turns: 1,
|
|
216
|
+
total_cost_usd: 0,
|
|
217
|
+
usage: {},
|
|
218
|
+
modelUsage: {},
|
|
219
|
+
permission_denials: [],
|
|
220
|
+
},
|
|
221
|
+
]),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
await provider.spawn({ taskId: 'T003', prompt: 'Default tools.' });
|
|
225
|
+
|
|
226
|
+
expect(queryMock).toHaveBeenCalledWith(
|
|
227
|
+
expect.objectContaining({
|
|
228
|
+
options: expect.objectContaining({
|
|
229
|
+
allowedTools: expect.arrayContaining(['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']),
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('sets permissionMode to bypassPermissions', async () => {
|
|
236
|
+
const queryMock = await getQueryMock();
|
|
237
|
+
queryMock.mockReturnValue(
|
|
238
|
+
makeQueryIterator([
|
|
239
|
+
{
|
|
240
|
+
type: 'result',
|
|
241
|
+
subtype: 'success',
|
|
242
|
+
session_id: 'sess-perm',
|
|
243
|
+
result: '',
|
|
244
|
+
is_error: false,
|
|
245
|
+
duration_ms: 5,
|
|
246
|
+
duration_api_ms: 4,
|
|
247
|
+
num_turns: 1,
|
|
248
|
+
total_cost_usd: 0,
|
|
249
|
+
usage: {},
|
|
250
|
+
modelUsage: {},
|
|
251
|
+
permission_denials: [],
|
|
252
|
+
},
|
|
253
|
+
]),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await provider.spawn({ taskId: 'T004', prompt: 'Permissions.' });
|
|
257
|
+
|
|
258
|
+
expect(queryMock).toHaveBeenCalledWith(
|
|
259
|
+
expect.objectContaining({
|
|
260
|
+
options: expect.objectContaining({
|
|
261
|
+
permissionMode: 'bypassPermissions',
|
|
262
|
+
allowDangerouslySkipPermissions: true,
|
|
263
|
+
}),
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// -------------------------------------------------------------------------
|
|
270
|
+
// spawn — error paths
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
describe('spawn() — error handling', () => {
|
|
274
|
+
it('returns failed status on SDK error subtype', async () => {
|
|
275
|
+
const queryMock = await getQueryMock();
|
|
276
|
+
queryMock.mockReturnValue(
|
|
277
|
+
makeQueryIterator([
|
|
278
|
+
{
|
|
279
|
+
type: 'result',
|
|
280
|
+
subtype: 'error_during_execution',
|
|
281
|
+
session_id: 'sess-err',
|
|
282
|
+
is_error: true,
|
|
283
|
+
errors: ['Something went wrong'],
|
|
284
|
+
duration_ms: 50,
|
|
285
|
+
duration_api_ms: 40,
|
|
286
|
+
num_turns: 1,
|
|
287
|
+
total_cost_usd: 0,
|
|
288
|
+
usage: {},
|
|
289
|
+
modelUsage: {},
|
|
290
|
+
permission_denials: [],
|
|
291
|
+
},
|
|
292
|
+
]),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const result = await provider.spawn({ taskId: 'T005', prompt: 'Fail.' });
|
|
296
|
+
|
|
297
|
+
expect(result.status).toBe('failed');
|
|
298
|
+
expect(result.exitCode).toBe(1);
|
|
299
|
+
expect(result.error).toContain('Something went wrong');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns failed status when query() throws', async () => {
|
|
303
|
+
const queryMock = await getQueryMock();
|
|
304
|
+
queryMock.mockImplementation(() => {
|
|
305
|
+
throw new Error('Network error');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = await provider.spawn({ taskId: 'T006', prompt: 'Throw.' });
|
|
309
|
+
|
|
310
|
+
expect(result.status).toBe('failed');
|
|
311
|
+
expect(result.exitCode).toBe(1);
|
|
312
|
+
expect(result.error).toContain('Network error');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('returns failed status when error_max_turns reached', async () => {
|
|
316
|
+
const queryMock = await getQueryMock();
|
|
317
|
+
queryMock.mockReturnValue(
|
|
318
|
+
makeQueryIterator([
|
|
319
|
+
{
|
|
320
|
+
type: 'result',
|
|
321
|
+
subtype: 'error_max_turns',
|
|
322
|
+
session_id: 'sess-max',
|
|
323
|
+
is_error: true,
|
|
324
|
+
errors: [],
|
|
325
|
+
duration_ms: 200,
|
|
326
|
+
duration_api_ms: 180,
|
|
327
|
+
num_turns: 10,
|
|
328
|
+
total_cost_usd: 0.05,
|
|
329
|
+
usage: {},
|
|
330
|
+
modelUsage: {},
|
|
331
|
+
permission_denials: [],
|
|
332
|
+
},
|
|
333
|
+
]),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const result = await provider.spawn({ taskId: 'T007', prompt: 'Max turns.' });
|
|
337
|
+
|
|
338
|
+
expect(result.status).toBe('failed');
|
|
339
|
+
expect(result.exitCode).toBe(1);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
// Session resume
|
|
345
|
+
// -------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
describe('spawn() — session resume', () => {
|
|
348
|
+
it('passes resume option when resumeSessionId is provided', async () => {
|
|
349
|
+
const queryMock = await getQueryMock();
|
|
350
|
+
queryMock.mockReturnValue(
|
|
351
|
+
makeQueryIterator([
|
|
352
|
+
{
|
|
353
|
+
type: 'result',
|
|
354
|
+
subtype: 'success',
|
|
355
|
+
session_id: 'sess-resume',
|
|
356
|
+
result: 'resumed',
|
|
357
|
+
is_error: false,
|
|
358
|
+
duration_ms: 20,
|
|
359
|
+
duration_api_ms: 15,
|
|
360
|
+
num_turns: 1,
|
|
361
|
+
total_cost_usd: 0,
|
|
362
|
+
usage: {},
|
|
363
|
+
modelUsage: {},
|
|
364
|
+
permission_denials: [],
|
|
365
|
+
},
|
|
366
|
+
]),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
await provider.spawn({
|
|
370
|
+
taskId: 'T008',
|
|
371
|
+
prompt: 'Continue work.',
|
|
372
|
+
options: { resumeSessionId: 'prior-session-id' },
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(queryMock).toHaveBeenCalledWith(
|
|
376
|
+
expect.objectContaining({
|
|
377
|
+
options: expect.objectContaining({
|
|
378
|
+
resume: 'prior-session-id',
|
|
379
|
+
}),
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// SessionStore unit tests
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
describe('SessionStore', () => {
|
|
391
|
+
it('stores and retrieves entries', async () => {
|
|
392
|
+
const { SessionStore } = await import('../session-store.js');
|
|
393
|
+
const store = new SessionStore();
|
|
394
|
+
|
|
395
|
+
store.add({ instanceId: 'i1', sessionId: undefined, taskId: 'T1', startTime: '2026-01-01' });
|
|
396
|
+
expect(store.get('i1')).toBeDefined();
|
|
397
|
+
expect(store.size()).toBe(1);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('updates session ID', async () => {
|
|
401
|
+
const { SessionStore } = await import('../session-store.js');
|
|
402
|
+
const store = new SessionStore();
|
|
403
|
+
|
|
404
|
+
store.add({ instanceId: 'i2', sessionId: undefined, taskId: 'T2', startTime: '2026-01-01' });
|
|
405
|
+
store.setSessionId('i2', 'sdk-session-123');
|
|
406
|
+
expect(store.get('i2')?.sessionId).toBe('sdk-session-123');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('removes entries', async () => {
|
|
410
|
+
const { SessionStore } = await import('../session-store.js');
|
|
411
|
+
const store = new SessionStore();
|
|
412
|
+
|
|
413
|
+
store.add({ instanceId: 'i3', sessionId: undefined, taskId: 'T3', startTime: '2026-01-01' });
|
|
414
|
+
store.remove('i3');
|
|
415
|
+
expect(store.get('i3')).toBeUndefined();
|
|
416
|
+
expect(store.size()).toBe(0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('lists active entries', async () => {
|
|
420
|
+
const { SessionStore } = await import('../session-store.js');
|
|
421
|
+
const store = new SessionStore();
|
|
422
|
+
|
|
423
|
+
store.add({ instanceId: 'i4', sessionId: undefined, taskId: 'T4', startTime: '2026-01-01' });
|
|
424
|
+
store.add({ instanceId: 'i5', sessionId: undefined, taskId: 'T5', startTime: '2026-01-02' });
|
|
425
|
+
expect(store.listActive()).toHaveLength(2);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// ToolBridge unit tests
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
describe('resolveTools()', () => {
|
|
434
|
+
it('returns default tools when called with no args', async () => {
|
|
435
|
+
const { resolveTools, DEFAULT_TOOLS } = await import('../tool-bridge.js');
|
|
436
|
+
expect(resolveTools()).toEqual([...DEFAULT_TOOLS]);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('returns provided list unchanged', async () => {
|
|
440
|
+
const { resolveTools } = await import('../tool-bridge.js');
|
|
441
|
+
expect(resolveTools(['Read', 'Bash'])).toEqual(['Read', 'Bash']);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('returns default tools when empty array passed', async () => {
|
|
445
|
+
const { resolveTools, DEFAULT_TOOLS } = await import('../tool-bridge.js');
|
|
446
|
+
expect(resolveTools([])).toEqual([...DEFAULT_TOOLS]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* Claude Agent SDK spawn provider for CLEO.
|
|
5
|
+
* Uses `@anthropic-ai/claude-agent-sdk` instead of the CLI for programmatic
|
|
6
|
+
* subagent execution with structured output and session tracking.
|
|
7
|
+
*
|
|
8
|
+
* Enabled via `provider.claude.mode = 'sdk'` in CLEO config.
|
|
9
|
+
*
|
|
10
|
+
* @task T581
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type { McpServerMap, McpStdioConfig } from './mcp-registry.js';
|
|
14
|
+
export { getServers } from './mcp-registry.js';
|
|
15
|
+
export type { SessionEntry } from './session-store.js';
|
|
16
|
+
export { SessionStore } from './session-store.js';
|
|
17
|
+
export { ClaudeSDKSpawnProvider } from './spawn.js';
|
|
18
|
+
export { DEFAULT_TOOLS, resolveTools } from './tool-bridge.js';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Registry for Claude SDK Spawn Provider
|
|
3
|
+
*
|
|
4
|
+
* Resolves available CLEO MCP servers and returns their configurations in the
|
|
5
|
+
* format expected by the SDK's `mcpServers` option. Each server is represented
|
|
6
|
+
* as an `McpStdioServerConfig` (command + optional args/env).
|
|
7
|
+
*
|
|
8
|
+
* Resolution is best-effort: if a server binary cannot be found in PATH or
|
|
9
|
+
* `node_modules/.bin/`, it is silently omitted from the returned map. This
|
|
10
|
+
* ensures agents always spawn even when some MCP servers are unavailable.
|
|
11
|
+
*
|
|
12
|
+
* @task T581
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Minimal stdio MCP server configuration understood by the SDK. */
|
|
19
|
+
export interface McpStdioConfig {
|
|
20
|
+
type?: 'stdio';
|
|
21
|
+
command: string;
|
|
22
|
+
args?: string[];
|
|
23
|
+
env?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Map of server name to its stdio configuration. */
|
|
27
|
+
export type McpServerMap = Record<string, McpStdioConfig>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Descriptor for a CLEO-provided MCP server candidate.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
interface McpServerCandidate {
|
|
35
|
+
/** Key used in the SDK `mcpServers` map. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Executable name to look up in PATH and node_modules/.bin/. */
|
|
38
|
+
binary: string;
|
|
39
|
+
/** Resolved extra args to pass after the binary. */
|
|
40
|
+
args?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Known CLEO MCP servers, in resolution priority order. */
|
|
44
|
+
const CLEO_MCP_CANDIDATES: readonly McpServerCandidate[] = [
|
|
45
|
+
{ name: 'brain', binary: 'cleo-mcp-brain' },
|
|
46
|
+
{ name: 'nexus', binary: 'cleo-mcp-nexus' },
|
|
47
|
+
{ name: 'tasks', binary: 'cleo-mcp-tasks' },
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Locate a binary in `node_modules/.bin/` relative to the given directory.
|
|
52
|
+
*
|
|
53
|
+
* @param binary - Executable name without path
|
|
54
|
+
* @param workingDir - Project root to resolve `.bin/` from
|
|
55
|
+
* @returns Absolute path if found, otherwise undefined
|
|
56
|
+
*/
|
|
57
|
+
function findInNodeModules(binary: string, workingDir: string): string | undefined {
|
|
58
|
+
const binPath = join(workingDir, 'node_modules', '.bin', binary);
|
|
59
|
+
if (existsSync(binPath)) {
|
|
60
|
+
return binPath;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve available CLEO MCP servers for the given working directory.
|
|
67
|
+
*
|
|
68
|
+
* Checks each known CLEO MCP server candidate against `node_modules/.bin/`
|
|
69
|
+
* in the provided directory. Only servers whose binary can be located are
|
|
70
|
+
* included in the returned map.
|
|
71
|
+
*
|
|
72
|
+
* @param workingDirectory - Project root directory for binary resolution
|
|
73
|
+
* @returns Map of available server name to stdio config (may be empty)
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* const servers = getServers('/path/to/project');
|
|
78
|
+
* // { brain: { type: 'stdio', command: '/path/to/project/node_modules/.bin/cleo-mcp-brain' } }
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function getServers(workingDirectory: string): McpServerMap {
|
|
82
|
+
const result: McpServerMap = {};
|
|
83
|
+
|
|
84
|
+
for (const candidate of CLEO_MCP_CANDIDATES) {
|
|
85
|
+
const resolvedPath = findInNodeModules(candidate.binary, workingDirectory);
|
|
86
|
+
if (resolvedPath) {
|
|
87
|
+
result[candidate.name] = {
|
|
88
|
+
type: 'stdio',
|
|
89
|
+
command: resolvedPath,
|
|
90
|
+
...(candidate.args ? { args: candidate.args } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Store for Claude SDK Spawn Provider
|
|
3
|
+
*
|
|
4
|
+
* Maintains an in-memory map of active SDK sessions keyed by instanceId.
|
|
5
|
+
* Each entry records the session ID returned by the SDK, the task ID, and
|
|
6
|
+
* the start time. Session IDs can be used with `options: { resume: sessionId }`
|
|
7
|
+
* in subsequent `query()` calls for multi-turn continuations.
|
|
8
|
+
*
|
|
9
|
+
* Persistence to conduit.db is intentionally deferred: the in-memory store
|
|
10
|
+
* is sufficient for single-process lifetimes and avoids coupling the spawn
|
|
11
|
+
* provider to the conduit subsystem at construction time.
|
|
12
|
+
*
|
|
13
|
+
* @task T581
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A single tracked SDK session entry. */
|
|
17
|
+
export interface SessionEntry {
|
|
18
|
+
/** Unique instance ID assigned at spawn time. */
|
|
19
|
+
instanceId: string;
|
|
20
|
+
/** Claude SDK session ID returned in the first SDK message (if captured). */
|
|
21
|
+
sessionId: string | undefined;
|
|
22
|
+
/** CLEO task ID this session is executing. */
|
|
23
|
+
taskId: string;
|
|
24
|
+
/** ISO timestamp when the session was created. */
|
|
25
|
+
startTime: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* In-memory store for active Claude SDK sessions.
|
|
30
|
+
*
|
|
31
|
+
* Provides CRUD operations over a `Map<instanceId, SessionEntry>`.
|
|
32
|
+
* Thread-safe within a single Node.js process (single-threaded event loop).
|
|
33
|
+
*/
|
|
34
|
+
export class SessionStore {
|
|
35
|
+
private readonly store = new Map<string, SessionEntry>();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register a new session entry.
|
|
39
|
+
*
|
|
40
|
+
* @param entry - The session entry to add
|
|
41
|
+
*/
|
|
42
|
+
add(entry: SessionEntry): void {
|
|
43
|
+
this.store.set(entry.instanceId, entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Update the SDK session ID for an existing entry once it is received
|
|
48
|
+
* from the first SDK message.
|
|
49
|
+
*
|
|
50
|
+
* No-op if the instanceId is not found.
|
|
51
|
+
*
|
|
52
|
+
* @param instanceId - ID of the spawn instance
|
|
53
|
+
* @param sessionId - SDK-assigned session identifier
|
|
54
|
+
*/
|
|
55
|
+
setSessionId(instanceId: string, sessionId: string): void {
|
|
56
|
+
const entry = this.store.get(instanceId);
|
|
57
|
+
if (entry) {
|
|
58
|
+
entry.sessionId = sessionId;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Retrieve an entry by instance ID.
|
|
64
|
+
*
|
|
65
|
+
* @param instanceId - ID of the spawn instance
|
|
66
|
+
* @returns The session entry, or undefined if not found
|
|
67
|
+
*/
|
|
68
|
+
get(instanceId: string): SessionEntry | undefined {
|
|
69
|
+
return this.store.get(instanceId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove an entry by instance ID.
|
|
74
|
+
*
|
|
75
|
+
* @param instanceId - ID of the spawn instance to remove
|
|
76
|
+
*/
|
|
77
|
+
remove(instanceId: string): void {
|
|
78
|
+
this.store.delete(instanceId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List all active session entries.
|
|
83
|
+
*
|
|
84
|
+
* @returns Array of all tracked session entries
|
|
85
|
+
*/
|
|
86
|
+
listActive(): SessionEntry[] {
|
|
87
|
+
return Array.from(this.store.values());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return the number of tracked sessions.
|
|
92
|
+
*/
|
|
93
|
+
size(): number {
|
|
94
|
+
return this.store.size;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear all tracked sessions. Intended for testing only.
|
|
99
|
+
*/
|
|
100
|
+
clear(): void {
|
|
101
|
+
this.store.clear();
|
|
102
|
+
}
|
|
103
|
+
}
|