agent-gear 1.1.6 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/acp/bridge.d.ts +13 -0
  2. package/dist/acp/bridge.d.ts.map +1 -1
  3. package/dist/acp/bridge.js +125 -3
  4. package/dist/acp/bridge.js.map +1 -1
  5. package/dist/acp/bridge.test.js +130 -7
  6. package/dist/acp/bridge.test.js.map +1 -1
  7. package/dist/acp/prober.d.ts +24 -0
  8. package/dist/acp/prober.d.ts.map +1 -0
  9. package/dist/acp/prober.js +53 -0
  10. package/dist/acp/prober.js.map +1 -0
  11. package/dist/acp/prober.test.d.ts +2 -0
  12. package/dist/acp/prober.test.d.ts.map +1 -0
  13. package/dist/acp/prober.test.js +94 -0
  14. package/dist/acp/prober.test.js.map +1 -0
  15. package/dist/acp/spawner.js +4 -4
  16. package/dist/acp/spawner.js.map +1 -1
  17. package/dist/acp/sync-bridge.d.ts +2 -0
  18. package/dist/acp/sync-bridge.d.ts.map +1 -1
  19. package/dist/acp/sync-bridge.js +6 -0
  20. package/dist/acp/sync-bridge.js.map +1 -1
  21. package/dist/commands/mcp-proxy.d.ts +10 -0
  22. package/dist/commands/mcp-proxy.d.ts.map +1 -1
  23. package/dist/commands/mcp-proxy.js +78 -24
  24. package/dist/commands/mcp-proxy.js.map +1 -1
  25. package/dist/socketio-client.d.ts.map +1 -1
  26. package/dist/socketio-client.js +57 -3
  27. package/dist/socketio-client.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/acp/bridge.test.ts +159 -7
  30. package/src/acp/bridge.ts +152 -3
  31. package/src/acp/prober.test.ts +107 -0
  32. package/src/acp/prober.ts +73 -0
  33. package/src/acp/spawner.ts +4 -4
  34. package/src/acp/sync-bridge.ts +8 -0
  35. package/src/commands/mcp-proxy.ts +91 -28
  36. package/src/socketio-client.ts +72 -3
  37. package/tests/acp-bridge.test.ts +119 -0
  38. package/tests/acp-spawner.test.ts +28 -1
  39. package/tests/acp-wire-elicitation.test.ts +158 -0
  40. package/tests/acp-wire-mcp.test.ts +114 -0
  41. package/tests/commands/mcp-proxy-dispatcher.test.ts +117 -0
  42. package/tests/fixtures/fake-acp-agent.mjs +122 -0
@@ -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
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ import { PassThrough } from 'node:stream';
4
+ import type { ChildProcess } from 'child_process';
5
+ import { createStdioDispatcher } from '../../src/commands/mcp-proxy.js';
6
+
7
+ function createFakeChild(): ChildProcess & EventEmitter {
8
+ const proc = new EventEmitter() as ChildProcess & EventEmitter;
9
+ Object.defineProperty(proc, 'stdin', { value: new PassThrough(), writable: true });
10
+ Object.defineProperty(proc, 'stdout', { value: new PassThrough(), writable: true });
11
+ Object.defineProperty(proc, 'stderr', { value: new PassThrough(), writable: true });
12
+ return proc;
13
+ }
14
+
15
+ function stdinWrites(child: ChildProcess): string[] {
16
+ const chunks: string[] = [];
17
+ (child.stdin as PassThrough).on('data', (d: Buffer) => chunks.push(d.toString()));
18
+ return chunks;
19
+ }
20
+
21
+ describe('createStdioDispatcher', () => {
22
+ let child: ChildProcess & EventEmitter;
23
+
24
+ beforeEach(() => {
25
+ child = createFakeChild();
26
+ vi.spyOn(console, 'log').mockImplementation(() => {});
27
+ vi.spyOn(console, 'error').mockImplementation(() => {});
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ it('should forward notifications fire-and-forget without waiting for a response', async () => {
35
+ const writes = stdinWrites(child);
36
+ const dispatcher = createStdioDispatcher(child);
37
+
38
+ const start = Date.now();
39
+ const result = await dispatcher.forward({ jsonrpc: '2.0', method: 'notifications/initialized' });
40
+
41
+ // must resolve immediately — the MCP server never responds to notifications
42
+ expect(Date.now() - start).toBeLessThan(1000);
43
+ expect(result).toBeNull();
44
+ expect(writes.join('')).toContain('"method":"notifications/initialized"');
45
+ });
46
+
47
+ it('should resolve a request when the child responds with the matching id', async () => {
48
+ const dispatcher = createStdioDispatcher(child);
49
+
50
+ const promise = dispatcher.forward({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
51
+ (child.stdout as PassThrough).write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true } }) + '\n');
52
+
53
+ await expect(promise).resolves.toEqual({ jsonrpc: '2.0', id: 1, result: { ok: true } });
54
+ });
55
+
56
+ it('should reassemble a JSON response split across multiple chunks', async () => {
57
+ const dispatcher = createStdioDispatcher(child);
58
+
59
+ const promise = dispatcher.forward({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
60
+ const full = JSON.stringify({ jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'probe_tool' }] } }) + '\n';
61
+ // TCP/stdio gives no message boundary guarantees: split the response
62
+ (child.stdout as PassThrough).write(full.slice(0, 10));
63
+ (child.stdout as PassThrough).write(full.slice(10, 40));
64
+ (child.stdout as PassThrough).write(full.slice(40));
65
+
66
+ await expect(promise).resolves.toEqual({ jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'probe_tool' }] } });
67
+ });
68
+
69
+ it('should match concurrent responses to their requests by id, even out of order', async () => {
70
+ const dispatcher = createStdioDispatcher(child);
71
+
72
+ const p1 = dispatcher.forward({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
73
+ const p2 = dispatcher.forward({ jsonrpc: '2.0', id: 2, method: 'tools/call' });
74
+
75
+ // second request answers first, and both arrive in one chunk
76
+ (child.stdout as PassThrough).write(
77
+ JSON.stringify({ jsonrpc: '2.0', id: 2, result: { content: [] } }) + '\n' +
78
+ JSON.stringify({ jsonrpc: '2.0', id: 1, result: { tools: [] } }) + '\n',
79
+ );
80
+
81
+ await expect(p1).resolves.toEqual({ jsonrpc: '2.0', id: 1, result: { tools: [] } });
82
+ await expect(p2).resolves.toEqual({ jsonrpc: '2.0', id: 2, result: { content: [] } });
83
+ });
84
+
85
+ it('should reject when the request times out', async () => {
86
+ vi.useFakeTimers();
87
+ try {
88
+ const dispatcher = createStdioDispatcher(child);
89
+
90
+ const promise = dispatcher.forward({ jsonrpc: '2.0', id: 9, method: 'tools/list' });
91
+ const assertion = expect(promise).rejects.toThrow('Request timeout waiting for tools/list');
92
+ await vi.advanceTimersByTimeAsync(30_000);
93
+ await assertion;
94
+ } finally {
95
+ vi.useRealTimers();
96
+ }
97
+ });
98
+
99
+ it('should reject all pending requests when the child exits', async () => {
100
+ const dispatcher = createStdioDispatcher(child);
101
+
102
+ const promise = dispatcher.forward({ jsonrpc: '2.0', id: 3, method: 'tools/list' });
103
+ child.emit('exit', 1);
104
+
105
+ await expect(promise).rejects.toThrow('MCP command exited with code 1');
106
+ });
107
+
108
+ it('should ignore non-JSON lines instead of failing the pending request', async () => {
109
+ const dispatcher = createStdioDispatcher(child);
110
+
111
+ const promise = dispatcher.forward({ jsonrpc: '2.0', id: 4, method: 'initialize' });
112
+ (child.stdout as PassThrough).write('[some log line that is not json]\n');
113
+ (child.stdout as PassThrough).write(JSON.stringify({ jsonrpc: '2.0', id: 4, result: {} }) + '\n');
114
+
115
+ await expect(promise).resolves.toEqual({ jsonrpc: '2.0', id: 4, result: {} });
116
+ });
117
+ });