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.
@@ -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) => {
@@ -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
+ });