agent-gear 1.1.6 → 1.1.7
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/acp/bridge.d.ts +13 -0
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +125 -3
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +130 -7
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/acp/spawner.js +4 -4
- package/dist/acp/spawner.js.map +1 -1
- package/dist/acp/sync-bridge.d.ts +2 -0
- package/dist/acp/sync-bridge.d.ts.map +1 -1
- package/dist/acp/sync-bridge.js +6 -0
- package/dist/acp/sync-bridge.js.map +1 -1
- package/dist/commands/mcp-proxy.d.ts +10 -0
- package/dist/commands/mcp-proxy.d.ts.map +1 -1
- package/dist/commands/mcp-proxy.js +78 -24
- package/dist/commands/mcp-proxy.js.map +1 -1
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +33 -3
- package/dist/socketio-client.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +159 -7
- package/src/acp/bridge.ts +152 -3
- package/src/acp/spawner.ts +4 -4
- package/src/acp/sync-bridge.ts +8 -0
- package/src/commands/mcp-proxy.ts +91 -28
- package/src/socketio-client.ts +43 -3
- package/tests/acp-bridge.test.ts +119 -0
- package/tests/acp-spawner.test.ts +28 -1
- package/tests/acp-wire-elicitation.test.ts +158 -0
- package/tests/acp-wire-mcp.test.ts +114 -0
- package/tests/commands/mcp-proxy-dispatcher.test.ts +117 -0
- package/tests/fixtures/fake-acp-agent.mjs +122 -0
package/src/socketio-client.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { gatewayManager } from './gateway.js';
|
|
|
18
18
|
const CONTROLLER_VERSION = '1.1.1';
|
|
19
19
|
const acpBridges = new Map<string, AcpSessionBridge>();
|
|
20
20
|
const permissionRequestBridge = new Map<string, AcpSessionBridge>();
|
|
21
|
+
const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
|
|
21
22
|
|
|
22
23
|
function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
|
|
23
24
|
const config = acpDetector.getSpawnerConfig(serverType);
|
|
@@ -48,6 +49,7 @@ interface AcpStartPayload {
|
|
|
48
49
|
serverType: string;
|
|
49
50
|
cwd: string;
|
|
50
51
|
mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
|
|
52
|
+
mcpEndpoint?: { url: string; token: string; serverNames: string[] };
|
|
51
53
|
additionalDirectories?: string[];
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -56,6 +58,19 @@ interface AcpMessagePayload {
|
|
|
56
58
|
message: { type: string; content: string; attachments?: Array<Record<string, unknown>> };
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
// Summarize the MCP configuration of an acp:start/acp:resume payload for logs,
|
|
62
|
+
// without leaking the endpoint token.
|
|
63
|
+
function describeMcpConfig(data: { mcpServers?: unknown[]; mcpEndpoint?: { url: string; serverNames?: string[] } }): string {
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
if (data.mcpServers && data.mcpServers.length > 0) {
|
|
66
|
+
parts.push(`mcpServers=${data.mcpServers.length}`);
|
|
67
|
+
}
|
|
68
|
+
if (data.mcpEndpoint) {
|
|
69
|
+
parts.push(`mcpEndpoint(url=${data.mcpEndpoint.url}, serverNames=${JSON.stringify(data.mcpEndpoint.serverNames ?? null)})`);
|
|
70
|
+
}
|
|
71
|
+
return parts.length > 0 ? `mcp=[${parts.join(' ')}]` : 'mcp=[none]';
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
interface AcpCancelPayload {
|
|
60
75
|
sessionId: string;
|
|
61
76
|
}
|
|
@@ -103,6 +118,7 @@ interface AcpResumePayload {
|
|
|
103
118
|
externalSessionId: string;
|
|
104
119
|
serverType: string;
|
|
105
120
|
cwd: string;
|
|
121
|
+
mcpEndpoint?: { url: string; token: string; serverNames: string[] };
|
|
106
122
|
additionalDirectories?: string[];
|
|
107
123
|
}
|
|
108
124
|
|
|
@@ -244,7 +260,7 @@ export function connectToServer(
|
|
|
244
260
|
});
|
|
245
261
|
|
|
246
262
|
socket.on('acp:start', async (data: AcpStartPayload) => {
|
|
247
|
-
console.log(`[ACP ctrl] acp:start received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} cwd=${data.cwd}`);
|
|
263
|
+
console.log(`[ACP ctrl] acp:start received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} cwd=${data.cwd} ${describeMcpConfig(data)}`);
|
|
248
264
|
|
|
249
265
|
try {
|
|
250
266
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
@@ -252,6 +268,7 @@ export function connectToServer(
|
|
|
252
268
|
sessionId: data.sessionId,
|
|
253
269
|
cwd: data.cwd,
|
|
254
270
|
mcpServers: data.mcpServers,
|
|
271
|
+
mcpEndpoint: data.mcpEndpoint,
|
|
255
272
|
additionalDirectories: data.additionalDirectories,
|
|
256
273
|
}, spawner);
|
|
257
274
|
|
|
@@ -261,6 +278,10 @@ export function connectToServer(
|
|
|
261
278
|
permissionRequestBridge.set(requestId, bridge);
|
|
262
279
|
});
|
|
263
280
|
|
|
281
|
+
bridge.on('elicitation:sent', (requestId: string) => {
|
|
282
|
+
elicitationRequestBridge.set(requestId, bridge);
|
|
283
|
+
});
|
|
284
|
+
|
|
264
285
|
const onInitialized = (payload: Record<string, unknown>) => {
|
|
265
286
|
if (payload.sessionId !== data.sessionId) return;
|
|
266
287
|
bridge.off('acp:initialized', onInitialized);
|
|
@@ -292,7 +313,7 @@ export function connectToServer(
|
|
|
292
313
|
bridge.off('acp:initialized', onInitialized);
|
|
293
314
|
bridge.off('acp:stopped', onStopped);
|
|
294
315
|
acpBridges.delete(data.sessionId);
|
|
295
|
-
console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false`);
|
|
316
|
+
console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
296
317
|
socket.emit('acp:start:result', {
|
|
297
318
|
requestId: data.requestId,
|
|
298
319
|
sessionId: data.sessionId,
|
|
@@ -318,13 +339,14 @@ export function connectToServer(
|
|
|
318
339
|
});
|
|
319
340
|
|
|
320
341
|
socket.on('acp:resume', async (data: AcpResumePayload) => {
|
|
321
|
-
console.log(`[ACP ctrl] acp:resume received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} externalSessionId=${data.externalSessionId}`);
|
|
342
|
+
console.log(`[ACP ctrl] acp:resume received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} externalSessionId=${data.externalSessionId} ${describeMcpConfig(data)}`);
|
|
322
343
|
|
|
323
344
|
try {
|
|
324
345
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
325
346
|
const bridge = new AcpSessionBridge(socket, {
|
|
326
347
|
sessionId: data.sessionId,
|
|
327
348
|
cwd: data.cwd,
|
|
349
|
+
mcpEndpoint: data.mcpEndpoint,
|
|
328
350
|
additionalDirectories: data.additionalDirectories,
|
|
329
351
|
}, spawner);
|
|
330
352
|
|
|
@@ -334,6 +356,10 @@ export function connectToServer(
|
|
|
334
356
|
permissionRequestBridge.set(requestId, bridge);
|
|
335
357
|
});
|
|
336
358
|
|
|
359
|
+
bridge.on('elicitation:sent', (requestId: string) => {
|
|
360
|
+
elicitationRequestBridge.set(requestId, bridge);
|
|
361
|
+
});
|
|
362
|
+
|
|
337
363
|
const onInitialized = (payload: Record<string, unknown>) => {
|
|
338
364
|
if (payload.sessionId !== data.sessionId) return;
|
|
339
365
|
bridge.off('acp:initialized', onInitialized);
|
|
@@ -360,6 +386,7 @@ export function connectToServer(
|
|
|
360
386
|
bridge.off('acp:initialized', onInitialized);
|
|
361
387
|
bridge.off('acp:stopped', onStopped);
|
|
362
388
|
acpBridges.delete(data.sessionId);
|
|
389
|
+
console.log(`[ACP ctrl] emitting acp:resume:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
363
390
|
socket.emit('acp:resume:result', {
|
|
364
391
|
requestId: data.requestId,
|
|
365
392
|
sessionId: data.sessionId,
|
|
@@ -769,11 +796,24 @@ export function connectToServer(
|
|
|
769
796
|
}
|
|
770
797
|
});
|
|
771
798
|
|
|
799
|
+
socket.on('acp:elicitation:response', (data: {
|
|
800
|
+
requestId: string;
|
|
801
|
+
action: 'accept' | 'decline' | 'cancel';
|
|
802
|
+
content?: Record<string, unknown>;
|
|
803
|
+
}) => {
|
|
804
|
+
const bridge = elicitationRequestBridge.get(data.requestId);
|
|
805
|
+
if (bridge) {
|
|
806
|
+
elicitationRequestBridge.delete(data.requestId);
|
|
807
|
+
bridge.handleElicitationResponse(data.requestId, data.action, data.content);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
772
811
|
socket.on('disconnect', (reason) => {
|
|
773
812
|
console.log(`Disconnected: ${reason}`);
|
|
774
813
|
heartbeat.stop();
|
|
775
814
|
gatewayManager.closeAll();
|
|
776
815
|
permissionRequestBridge.clear();
|
|
816
|
+
elicitationRequestBridge.clear();
|
|
777
817
|
});
|
|
778
818
|
|
|
779
819
|
socket.on('connect_error', (err) => {
|
package/tests/acp-bridge.test.ts
CHANGED
|
@@ -20,6 +20,10 @@ let mockConnInstance = buildMockConn();
|
|
|
20
20
|
vi.mock('@agentclientprotocol/sdk', () => ({
|
|
21
21
|
ndJsonStream: vi.fn(() => ({})),
|
|
22
22
|
ClientSideConnection: vi.fn(function () { return mockConnInstance; }),
|
|
23
|
+
CreateElicitationRequest: {
|
|
24
|
+
isForm: (v: { mode?: string }) => v.mode === 'form',
|
|
25
|
+
isUrl: (v: { mode?: string }) => v.mode === 'url',
|
|
26
|
+
},
|
|
23
27
|
}));
|
|
24
28
|
|
|
25
29
|
import { AcpSessionBridge, extractConfigOptions } from '../../src/acp/bridge.js';
|
|
@@ -80,6 +84,7 @@ describe('AcpSessionBridge', () => {
|
|
|
80
84
|
session: {
|
|
81
85
|
configOptions: { boolean: {} },
|
|
82
86
|
},
|
|
87
|
+
elicitation: { form: {}, url: {} },
|
|
83
88
|
},
|
|
84
89
|
clientInfo: { name: 'agent-gear', version: '1.1.5' },
|
|
85
90
|
});
|
|
@@ -235,6 +240,7 @@ describe('AcpSessionBridge', () => {
|
|
|
235
240
|
expect(mc().resumeSession).toHaveBeenCalledWith({
|
|
236
241
|
sessionId: 'existing-sess-id',
|
|
237
242
|
cwd: '/home/user',
|
|
243
|
+
mcpServers: [],
|
|
238
244
|
});
|
|
239
245
|
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
240
246
|
sessionId: 'sess-1',
|
|
@@ -525,6 +531,119 @@ describe('AcpSessionBridge', () => {
|
|
|
525
531
|
});
|
|
526
532
|
});
|
|
527
533
|
|
|
534
|
+
describe('unstable_createElicitation', () => {
|
|
535
|
+
it('form 模式:emit acp:elicitation 并在响应后 resolve accept + content', async () => {
|
|
536
|
+
const bridge = createBridge();
|
|
537
|
+
const requestedSchema = {
|
|
538
|
+
type: 'object',
|
|
539
|
+
properties: { name: { type: 'string' } },
|
|
540
|
+
required: ['name'],
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const promise = bridge.unstable_createElicitation({
|
|
544
|
+
mode: 'form',
|
|
545
|
+
message: 'Need your name',
|
|
546
|
+
sessionId: 'acp-sess-abc',
|
|
547
|
+
toolCallId: 'tc-1',
|
|
548
|
+
requestedSchema,
|
|
549
|
+
} as never);
|
|
550
|
+
|
|
551
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:elicitation', expect.objectContaining({
|
|
552
|
+
sessionId: 'sess-1',
|
|
553
|
+
mode: 'form',
|
|
554
|
+
message: 'Need your name',
|
|
555
|
+
toolCallId: 'tc-1',
|
|
556
|
+
requestedSchema,
|
|
557
|
+
}));
|
|
558
|
+
|
|
559
|
+
const emitCall = mockSocket.emit.mock.calls.find((c: unknown[]) => c[0] === 'acp:elicitation');
|
|
560
|
+
const requestId = (emitCall![1] as { requestId: string }).requestId;
|
|
561
|
+
expect(requestId).toMatch(/^elic_/);
|
|
562
|
+
|
|
563
|
+
bridge.handleElicitationResponse(requestId, 'accept', { name: 'ash' });
|
|
564
|
+
|
|
565
|
+
const result = await promise;
|
|
566
|
+
expect(result).toEqual({ action: 'accept', content: { name: 'ash' } });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('url 模式:透传 elicitationId 与 url', async () => {
|
|
570
|
+
const bridge = createBridge();
|
|
571
|
+
|
|
572
|
+
const promise = bridge.unstable_createElicitation({
|
|
573
|
+
mode: 'url',
|
|
574
|
+
message: 'Authorize access',
|
|
575
|
+
sessionId: 'acp-sess-abc',
|
|
576
|
+
elicitationId: 'elic-agent-1',
|
|
577
|
+
url: 'https://example.com/auth',
|
|
578
|
+
} as never);
|
|
579
|
+
|
|
580
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:elicitation', expect.objectContaining({
|
|
581
|
+
sessionId: 'sess-1',
|
|
582
|
+
mode: 'url',
|
|
583
|
+
message: 'Authorize access',
|
|
584
|
+
elicitationId: 'elic-agent-1',
|
|
585
|
+
url: 'https://example.com/auth',
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
const emitCall = mockSocket.emit.mock.calls.find((c: unknown[]) => c[0] === 'acp:elicitation');
|
|
589
|
+
const requestId = (emitCall![1] as { requestId: string }).requestId;
|
|
590
|
+
bridge.handleElicitationResponse(requestId, 'decline');
|
|
591
|
+
|
|
592
|
+
const result = await promise;
|
|
593
|
+
expect(result).toEqual({ action: 'decline' });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('custom/未知模式:立即返回 cancel 且不 emit socket 事件', async () => {
|
|
597
|
+
const bridge = createBridge();
|
|
598
|
+
|
|
599
|
+
const result = await bridge.unstable_createElicitation({
|
|
600
|
+
mode: '_vendor_custom',
|
|
601
|
+
message: 'custom request',
|
|
602
|
+
sessionId: 'acp-sess-abc',
|
|
603
|
+
} as never);
|
|
604
|
+
|
|
605
|
+
expect(result).toEqual({ action: 'cancel' });
|
|
606
|
+
expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:elicitation', expect.anything());
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('65s 超时自动 resolve cancel', async () => {
|
|
610
|
+
const bridge = createBridge();
|
|
611
|
+
|
|
612
|
+
const promise = bridge.unstable_createElicitation({
|
|
613
|
+
mode: 'form',
|
|
614
|
+
message: 'm',
|
|
615
|
+
sessionId: 'acp-sess-abc',
|
|
616
|
+
requestedSchema: { type: 'object' },
|
|
617
|
+
} as never);
|
|
618
|
+
|
|
619
|
+
await vi.advanceTimersByTimeAsync(65_000);
|
|
620
|
+
|
|
621
|
+
const result = await promise;
|
|
622
|
+
expect(result).toEqual({ action: 'cancel' });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('handleElicitationResponse 未知 requestId 打 warn 不抛错', () => {
|
|
626
|
+
const bridge = createBridge();
|
|
627
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
628
|
+
|
|
629
|
+
expect(() => bridge.handleElicitationResponse('elic_unknown', 'cancel')).not.toThrow();
|
|
630
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('elic_unknown'));
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
describe('unstable_completeElicitation', () => {
|
|
635
|
+
it('emit acp:elicitation:complete', async () => {
|
|
636
|
+
const bridge = createBridge();
|
|
637
|
+
|
|
638
|
+
await bridge.unstable_completeElicitation({ elicitationId: 'elic-agent-1' } as never);
|
|
639
|
+
|
|
640
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:elicitation:complete', {
|
|
641
|
+
sessionId: 'sess-1',
|
|
642
|
+
elicitationId: 'elic-agent-1',
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
528
647
|
describe('setMode', () => {
|
|
529
648
|
it('should call connection.setSessionMode', async () => {
|
|
530
649
|
const bridge = createBridge();
|
|
@@ -68,7 +68,34 @@ describe('AcpSpawner', () => {
|
|
|
68
68
|
spawner.start();
|
|
69
69
|
const stderr = mockProcess.stderr as Readable;
|
|
70
70
|
stderr.emit('data', Buffer.from('some warning\n'));
|
|
71
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith('ACP stderr: some warning\n');
|
|
71
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('ACP stderr (session=sess-1): some warning\n');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should log resolved command, args and cwd on start', () => {
|
|
75
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
76
|
+
spawner.start();
|
|
77
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
78
|
+
'Starting ACP server: session=sess-1 cmd=/usr/bin/opencode args=["acp"] cwd=/tmp/project',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should log exit code, signal and spawn params on process exit', () => {
|
|
83
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
84
|
+
spawner.start();
|
|
85
|
+
mockProcess.emit('exit', 1, null);
|
|
86
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
87
|
+
'ACP process exited: session=sess-1 code=1 signal=null cmd=/usr/bin/opencode args=["acp"]',
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should log process error with session and command', () => {
|
|
92
|
+
spawner.start();
|
|
93
|
+
const error = new Error('spawn opencode ENOENT');
|
|
94
|
+
mockProcess.emit('error', error);
|
|
95
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
96
|
+
'ACP process error (session=sess-1, cmd=/usr/bin/opencode):',
|
|
97
|
+
error,
|
|
98
|
+
);
|
|
72
99
|
});
|
|
73
100
|
|
|
74
101
|
it('should resolve closed on process exit', async () => {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { readFileSync, rmSync, mkdtempSync, existsSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Force the real (unmocked) ACP SDK through the vite pipeline (same rationale
|
|
8
|
+
// as acp-wire-mcp.test.ts): keep actual SDK serialization behavior on the wire.
|
|
9
|
+
vi.mock('@agentclientprotocol/sdk', async (importOriginal) => {
|
|
10
|
+
return await importOriginal<typeof import('@agentclientprotocol/sdk')>();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
import { AcpSessionBridge } from '../../src/acp/bridge.js';
|
|
14
|
+
import { AcpSpawner } from '../../src/acp/spawner.js';
|
|
15
|
+
|
|
16
|
+
const FAKE_AGENT = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'fake-acp-agent.mjs');
|
|
17
|
+
|
|
18
|
+
interface CapturedEntry {
|
|
19
|
+
method: string;
|
|
20
|
+
params?: Record<string, unknown>;
|
|
21
|
+
response?: Record<string, unknown> | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Wire-level test: drives a REAL ACP client connection against a fake agent
|
|
25
|
+
// process that issues `elicitation/create` (the same wire behavior as an
|
|
26
|
+
// eliciting agent such as opencode), and asserts both the Socket.IO payload
|
|
27
|
+
// emitted by the bridge and the exact JSON-RPC response that goes back over
|
|
28
|
+
// stdio — no mocked ClientSideConnection.
|
|
29
|
+
describe('AcpSessionBridge - Elicitation wire format (ACP spec)', () => {
|
|
30
|
+
let workdir: string;
|
|
31
|
+
let captureFile: string;
|
|
32
|
+
let bridge: AcpSessionBridge | null = null;
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
delete process.env.CAPTURE_FILE;
|
|
36
|
+
delete process.env.FAKE_AGENT_ELICITATION;
|
|
37
|
+
if (bridge) {
|
|
38
|
+
await bridge.stop().catch(() => {});
|
|
39
|
+
bridge = null;
|
|
40
|
+
}
|
|
41
|
+
if (workdir) rmSync(workdir, { recursive: true, force: true });
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function setup(mode: 'form' | 'url') {
|
|
46
|
+
workdir = mkdtempSync(join(tmpdir(), 'acp-wire-elic-'));
|
|
47
|
+
captureFile = join(workdir, 'capture.jsonl');
|
|
48
|
+
process.env.CAPTURE_FILE = captureFile;
|
|
49
|
+
process.env.FAKE_AGENT_ELICITATION = mode;
|
|
50
|
+
const spawner = new AcpSpawner({
|
|
51
|
+
sessionId: 'sess-wire',
|
|
52
|
+
cwd: workdir,
|
|
53
|
+
command: process.execPath,
|
|
54
|
+
args: [FAKE_AGENT],
|
|
55
|
+
});
|
|
56
|
+
const socket = { emit: vi.fn(), on: vi.fn(), off: vi.fn() };
|
|
57
|
+
bridge = new AcpSessionBridge(socket as never, {
|
|
58
|
+
sessionId: 'sess-wire',
|
|
59
|
+
cwd: workdir,
|
|
60
|
+
}, spawner);
|
|
61
|
+
return socket;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readCapture(): CapturedEntry[] {
|
|
65
|
+
if (!existsSync(captureFile)) return [];
|
|
66
|
+
return readFileSync(captureFile, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function emittedCall(socket: { emit: ReturnType<typeof vi.fn> }, event: string) {
|
|
70
|
+
return socket.emit.mock.calls.find((c) => c[0] === event);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it('should advertise elicitation capabilities in initialize on the wire', async () => {
|
|
74
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
75
|
+
setup('form');
|
|
76
|
+
|
|
77
|
+
await bridge!.start();
|
|
78
|
+
|
|
79
|
+
const init = readCapture().find((e) => e.method === 'initialize');
|
|
80
|
+
expect(init).toBeDefined();
|
|
81
|
+
const capabilities = init!.params!.clientCapabilities as Record<string, unknown>;
|
|
82
|
+
expect(capabilities.elicitation).toEqual({ form: {}, url: {} });
|
|
83
|
+
}, 15000);
|
|
84
|
+
|
|
85
|
+
it('should forward form elicitation and respond with spec-compliant payload', async () => {
|
|
86
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
87
|
+
const socket = setup('form');
|
|
88
|
+
|
|
89
|
+
await bridge!.start();
|
|
90
|
+
bridge!.sendInput({ type: 'text', content: 'please refactor this' });
|
|
91
|
+
|
|
92
|
+
// The agent's elicitation/create must surface as an acp:elicitation socket event
|
|
93
|
+
await vi.waitFor(() => {
|
|
94
|
+
expect(emittedCall(socket, 'acp:elicitation')).toBeDefined();
|
|
95
|
+
}, { timeout: 10000 });
|
|
96
|
+
|
|
97
|
+
const [, payload] = emittedCall(socket, 'acp:elicitation')!;
|
|
98
|
+
expect(payload.sessionId).toBe('sess-wire');
|
|
99
|
+
expect(payload.mode).toBe('form');
|
|
100
|
+
expect(payload.message).toBe('How should I approach this refactoring?');
|
|
101
|
+
expect(payload.requestedSchema.required).toEqual(['strategy']);
|
|
102
|
+
expect(payload.requestId).toMatch(/^elic_/);
|
|
103
|
+
|
|
104
|
+
// Simulate the server returning the user's answer
|
|
105
|
+
bridge!.handleElicitationResponse(payload.requestId, 'accept', { strategy: 'balanced' });
|
|
106
|
+
|
|
107
|
+
// The agent must receive a spec-compliant CreateElicitationResponse on the wire
|
|
108
|
+
await vi.waitFor(() => {
|
|
109
|
+
const elicitation = readCapture().find((e) => e.method === 'elicitation/create');
|
|
110
|
+
expect(elicitation?.response).toBeDefined();
|
|
111
|
+
}, { timeout: 10000 });
|
|
112
|
+
|
|
113
|
+
const elicitation = readCapture().find((e) => e.method === 'elicitation/create');
|
|
114
|
+
expect(elicitation!.response).toEqual({ action: 'accept', content: { strategy: 'balanced' } });
|
|
115
|
+
|
|
116
|
+
// Prompt completes after the elicitation resolves
|
|
117
|
+
await vi.waitFor(() => {
|
|
118
|
+
expect(emittedCall(socket, 'acp:prompt:complete')).toBeDefined();
|
|
119
|
+
}, { timeout: 10000 });
|
|
120
|
+
}, 20000);
|
|
121
|
+
|
|
122
|
+
it('should forward url elicitation and relay the completion notification', async () => {
|
|
123
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
124
|
+
const socket = setup('url');
|
|
125
|
+
|
|
126
|
+
await bridge!.start();
|
|
127
|
+
bridge!.sendInput({ type: 'text', content: 'connect my account' });
|
|
128
|
+
|
|
129
|
+
await vi.waitFor(() => {
|
|
130
|
+
expect(emittedCall(socket, 'acp:elicitation')).toBeDefined();
|
|
131
|
+
}, { timeout: 10000 });
|
|
132
|
+
|
|
133
|
+
const [, payload] = emittedCall(socket, 'acp:elicitation')!;
|
|
134
|
+
expect(payload.mode).toBe('url');
|
|
135
|
+
expect(payload.elicitationId).toBe('fake-oauth-001');
|
|
136
|
+
expect(payload.url).toBe('https://agent.example.com/connect?elicitationId=fake-oauth-001');
|
|
137
|
+
expect(payload.message).toBe('Please authorize access to your repositories.');
|
|
138
|
+
|
|
139
|
+
// User consented to open the URL (accept without content)
|
|
140
|
+
bridge!.handleElicitationResponse(payload.requestId, 'accept');
|
|
141
|
+
|
|
142
|
+
await vi.waitFor(() => {
|
|
143
|
+
const elicitation = readCapture().find((e) => e.method === 'elicitation/create');
|
|
144
|
+
expect(elicitation?.response).toBeDefined();
|
|
145
|
+
}, { timeout: 10000 });
|
|
146
|
+
|
|
147
|
+
const elicitation = readCapture().find((e) => e.method === 'elicitation/create');
|
|
148
|
+
expect(elicitation!.response).toEqual({ action: 'accept' });
|
|
149
|
+
|
|
150
|
+
// The agent's elicitation/complete notification must be forwarded
|
|
151
|
+
await vi.waitFor(() => {
|
|
152
|
+
expect(emittedCall(socket, 'acp:elicitation:complete')).toBeDefined();
|
|
153
|
+
}, { timeout: 10000 });
|
|
154
|
+
|
|
155
|
+
const [, completePayload] = emittedCall(socket, 'acp:elicitation:complete')!;
|
|
156
|
+
expect(completePayload).toEqual({ sessionId: 'sess-wire', elicitationId: 'fake-oauth-001' });
|
|
157
|
+
}, 20000);
|
|
158
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { readFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Force the real (unmocked) ACP SDK through the vite pipeline: returning the
|
|
8
|
+
// original module keeps actual SDK serialization behavior while avoiding the
|
|
9
|
+
// node-native externalization that breaks .js -> .ts source resolution.
|
|
10
|
+
vi.mock('@agentclientprotocol/sdk', async (importOriginal) => {
|
|
11
|
+
return await importOriginal<typeof import('@agentclientprotocol/sdk')>();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
import { AcpSessionBridge } from '../../src/acp/bridge.js';
|
|
15
|
+
import { AcpSpawner } from '../../src/acp/spawner.js';
|
|
16
|
+
|
|
17
|
+
const FAKE_AGENT = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'fake-acp-agent.mjs');
|
|
18
|
+
|
|
19
|
+
// Wire-level test: drives a REAL ACP client connection (SDK serialization,
|
|
20
|
+
// ndJsonStream) against a fake agent process, and asserts the exact JSON-RPC
|
|
21
|
+
// payload that goes over stdin — no mocked ClientSideConnection.
|
|
22
|
+
describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
|
|
23
|
+
let workdir: string;
|
|
24
|
+
let captureFile: string;
|
|
25
|
+
let bridge: AcpSessionBridge | null = null;
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
delete process.env.CAPTURE_FILE;
|
|
29
|
+
if (bridge) {
|
|
30
|
+
await bridge.stop().catch(() => {});
|
|
31
|
+
bridge = null;
|
|
32
|
+
}
|
|
33
|
+
if (workdir) rmSync(workdir, { recursive: true, force: true });
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function setup() {
|
|
38
|
+
workdir = mkdtempSync(join(tmpdir(), 'acp-wire-'));
|
|
39
|
+
captureFile = join(workdir, 'capture.jsonl');
|
|
40
|
+
process.env.CAPTURE_FILE = captureFile;
|
|
41
|
+
const spawner = new AcpSpawner({
|
|
42
|
+
sessionId: 'sess-wire',
|
|
43
|
+
cwd: workdir,
|
|
44
|
+
command: process.execPath,
|
|
45
|
+
args: [FAKE_AGENT],
|
|
46
|
+
});
|
|
47
|
+
const socket = { emit: vi.fn(), on: vi.fn(), off: vi.fn() };
|
|
48
|
+
bridge = new AcpSessionBridge(socket as never, {
|
|
49
|
+
sessionId: 'sess-wire',
|
|
50
|
+
cwd: workdir,
|
|
51
|
+
mcpEndpoint: {
|
|
52
|
+
url: 'http://gateway/mcp/sess-wire',
|
|
53
|
+
token: 'wire-token',
|
|
54
|
+
serverNames: ['fluentui-mcp', 'fs-mcp'],
|
|
55
|
+
},
|
|
56
|
+
}, spawner);
|
|
57
|
+
return socket;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readCapture(): Array<{ method: string; params: Record<string, unknown> }> {
|
|
61
|
+
return readFileSync(captureFile, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('should send session/new with spec-compliant mcpServers on the wire', async () => {
|
|
65
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
66
|
+
setup();
|
|
67
|
+
|
|
68
|
+
await bridge!.start();
|
|
69
|
+
|
|
70
|
+
const captured = readCapture();
|
|
71
|
+
expect(captured).toHaveLength(1);
|
|
72
|
+
expect(captured[0].method).toBe('session/new');
|
|
73
|
+
// ACP spec (session/new): mcpServers entries for HTTP transport are
|
|
74
|
+
// { type: "http", name, url, headers: [{ name, value }] }
|
|
75
|
+
expect(captured[0].params.mcpServers).toEqual([
|
|
76
|
+
{
|
|
77
|
+
type: 'http',
|
|
78
|
+
name: 'fluentui-mcp',
|
|
79
|
+
url: 'http://gateway/mcp/sess-wire/fluentui-mcp',
|
|
80
|
+
headers: [{ name: 'Authorization', value: 'Bearer wire-token' }],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'http',
|
|
84
|
+
name: 'fs-mcp',
|
|
85
|
+
url: 'http://gateway/mcp/sess-wire/fs-mcp',
|
|
86
|
+
headers: [{ name: 'Authorization', value: 'Bearer wire-token' }],
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
expect(captured[0].params.cwd).toBe(workdir);
|
|
90
|
+
|
|
91
|
+
// The MCP handoff must be visible in logs, without leaking the token
|
|
92
|
+
const logLines = logSpy.mock.calls.map((c) => String(c[0]));
|
|
93
|
+
const mcpLog = logLines.find((l) => l.includes('passing MCP servers via session/new'));
|
|
94
|
+
expect(mcpLog).toBeDefined();
|
|
95
|
+
expect(mcpLog).toContain('fluentui-mcp(type=http, url=http://gateway/mcp/sess-wire/fluentui-mcp, headers=Authorization)');
|
|
96
|
+
expect(mcpLog).not.toContain('wire-token');
|
|
97
|
+
}, 15000);
|
|
98
|
+
|
|
99
|
+
it('should send session/resume with spec-compliant mcpServers on the wire', async () => {
|
|
100
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
101
|
+
setup();
|
|
102
|
+
|
|
103
|
+
await bridge!.resume('ext-session-1');
|
|
104
|
+
|
|
105
|
+
const captured = readCapture();
|
|
106
|
+
expect(captured).toHaveLength(1);
|
|
107
|
+
expect(captured[0].method).toBe('session/resume');
|
|
108
|
+
expect(captured[0].params.sessionId).toBe('ext-session-1');
|
|
109
|
+
expect(captured[0].params.mcpServers).toEqual([
|
|
110
|
+
expect.objectContaining({ type: 'http', name: 'fluentui-mcp' }),
|
|
111
|
+
expect.objectContaining({ type: 'http', name: 'fs-mcp' }),
|
|
112
|
+
]);
|
|
113
|
+
}, 15000);
|
|
114
|
+
});
|