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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Fake ACP agent used by acp-wire-mcp.test.ts and acp-wire-elicitation.test.ts.
|
|
2
|
+
// Speaks ndjson JSON-RPC over stdio: answers initialize/session/new/session/resume
|
|
3
|
+
// and records every session/new & session/resume params payload to CAPTURE_FILE
|
|
4
|
+
// (one JSON per line) so tests can assert the exact wire format.
|
|
5
|
+
//
|
|
6
|
+
// When FAKE_AGENT_ELICITATION=form|url is set, a session/prompt makes the agent
|
|
7
|
+
// behave like an eliciting agent (e.g. opencode asking a question):
|
|
8
|
+
// 1. capture the initialize params (clientCapabilities advertisement)
|
|
9
|
+
// 2. send an outgoing `elicitation/create` request and await the client response
|
|
10
|
+
// 3. capture the elicitation response payload exactly as received on the wire
|
|
11
|
+
// 4. reply to session/prompt with stopReason "end_turn"
|
|
12
|
+
// 5. (url mode only) send an `elicitation/complete` notification afterwards
|
|
13
|
+
import { appendFileSync } from 'node:fs';
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
|
|
16
|
+
const captureFile = process.env.CAPTURE_FILE;
|
|
17
|
+
const elicitationMode = process.env.FAKE_AGENT_ELICITATION; // 'form' | 'url' | undefined
|
|
18
|
+
|
|
19
|
+
const rl = createInterface({ input: process.stdin });
|
|
20
|
+
|
|
21
|
+
let nextOutgoingId = 1000;
|
|
22
|
+
// outgoing request id -> continuation invoked with the client's response result
|
|
23
|
+
const pendingOutgoing = new Map();
|
|
24
|
+
|
|
25
|
+
function reply(id, result) {
|
|
26
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function request(method, params, onResult) {
|
|
30
|
+
const id = nextOutgoingId++;
|
|
31
|
+
pendingOutgoing.set(id, onResult);
|
|
32
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function notify(method, params) {
|
|
36
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function capture(entry) {
|
|
40
|
+
if (captureFile) {
|
|
41
|
+
appendFileSync(captureFile, JSON.stringify(entry) + '\n');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handlePrompt(msg) {
|
|
46
|
+
if (!elicitationMode) {
|
|
47
|
+
reply(msg.id, { stopReason: 'end_turn' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sessionId = msg.params?.sessionId ?? 'fake-acp-session-1';
|
|
52
|
+
const params =
|
|
53
|
+
elicitationMode === 'form'
|
|
54
|
+
? {
|
|
55
|
+
sessionId,
|
|
56
|
+
mode: 'form',
|
|
57
|
+
message: 'How should I approach this refactoring?',
|
|
58
|
+
requestedSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
strategy: { type: 'string', enum: ['conservative', 'balanced', 'aggressive'] },
|
|
62
|
+
},
|
|
63
|
+
required: ['strategy'],
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
: {
|
|
67
|
+
sessionId,
|
|
68
|
+
mode: 'url',
|
|
69
|
+
elicitationId: 'fake-oauth-001',
|
|
70
|
+
url: 'https://agent.example.com/connect?elicitationId=fake-oauth-001',
|
|
71
|
+
message: 'Please authorize access to your repositories.',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
request('elicitation/create', params, (result) => {
|
|
75
|
+
capture({ method: 'elicitation/create', response: result ?? null });
|
|
76
|
+
reply(msg.id, { stopReason: 'end_turn' });
|
|
77
|
+
if (elicitationMode === 'url') {
|
|
78
|
+
notify('elicitation/complete', { elicitationId: 'fake-oauth-001' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
rl.on('line', (line) => {
|
|
84
|
+
let msg;
|
|
85
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
86
|
+
|
|
87
|
+
// Response to an outgoing request (has id, no method)
|
|
88
|
+
if (msg.id !== undefined && !msg.method) {
|
|
89
|
+
const continuation = pendingOutgoing.get(msg.id);
|
|
90
|
+
if (continuation) {
|
|
91
|
+
pendingOutgoing.delete(msg.id);
|
|
92
|
+
continuation(msg.result);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (msg.id === undefined || !msg.method) return; // ignore notifications
|
|
98
|
+
|
|
99
|
+
switch (msg.method) {
|
|
100
|
+
case 'initialize':
|
|
101
|
+
if (elicitationMode) {
|
|
102
|
+
capture({ method: 'initialize', params: msg.params });
|
|
103
|
+
}
|
|
104
|
+
reply(msg.id, {
|
|
105
|
+
protocolVersion: 1,
|
|
106
|
+
agentCapabilities: { mcpCapabilities: { http: true, sse: true } },
|
|
107
|
+
agentInfo: { name: 'fake-acp-agent', version: '0.0.1' },
|
|
108
|
+
authMethods: [],
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
case 'session/new':
|
|
112
|
+
case 'session/resume':
|
|
113
|
+
capture({ method: msg.method, params: msg.params });
|
|
114
|
+
reply(msg.id, { sessionId: 'fake-acp-session-1' });
|
|
115
|
+
break;
|
|
116
|
+
case 'session/prompt':
|
|
117
|
+
handlePrompt(msg);
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
reply(msg.id, {});
|
|
121
|
+
}
|
|
122
|
+
});
|