agent-gear 1.0.0
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/AGENTS.md +67 -0
- package/dist/acp/bridge.d.ts +56 -0
- package/dist/acp/bridge.d.ts.map +1 -0
- package/dist/acp/bridge.js +295 -0
- package/dist/acp/bridge.js.map +1 -0
- package/dist/acp/detector.d.ts +13 -0
- package/dist/acp/detector.d.ts.map +1 -0
- package/dist/acp/detector.js +29 -0
- package/dist/acp/detector.js.map +1 -0
- package/dist/acp/spawner.d.ts +24 -0
- package/dist/acp/spawner.d.ts.map +1 -0
- package/dist/acp/spawner.js +82 -0
- package/dist/acp/spawner.js.map +1 -0
- package/dist/acp/sync-bridge.d.ts +21 -0
- package/dist/acp/sync-bridge.d.ts.map +1 -0
- package/dist/acp/sync-bridge.js +75 -0
- package/dist/acp/sync-bridge.js.map +1 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +32 -0
- package/dist/daemon.js.map +1 -0
- package/dist/git-handler.d.ts +17 -0
- package/dist/git-handler.d.ts.map +1 -0
- package/dist/git-handler.js +60 -0
- package/dist/git-handler.js.map +1 -0
- package/dist/git-handler.test.d.ts +2 -0
- package/dist/git-handler.test.d.ts.map +1 -0
- package/dist/git-handler.test.js +127 -0
- package/dist/git-handler.test.js.map +1 -0
- package/dist/heartbeat.d.ts +6 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +16 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/socketio-client.d.ts +6 -0
- package/dist/socketio-client.d.ts.map +1 -0
- package/dist/socketio-client.js +385 -0
- package/dist/socketio-client.js.map +1 -0
- package/package.json +34 -0
- package/src/acp/bridge.ts +327 -0
- package/src/acp/detector.ts +40 -0
- package/src/acp/spawner.ts +94 -0
- package/src/acp/sync-bridge.ts +111 -0
- package/src/config.ts +75 -0
- package/src/daemon.ts +33 -0
- package/src/git-handler.test.ts +153 -0
- package/src/git-handler.ts +75 -0
- package/src/heartbeat.ts +18 -0
- package/src/index.ts +60 -0
- package/src/socketio-client.ts +497 -0
- package/tests/acp-bridge.test.ts +473 -0
- package/tests/acp-detector.test.ts +70 -0
- package/tests/acp-spawner.test.ts +149 -0
- package/tests/acp-sync-bridge.test.ts +263 -0
- package/tests/config.test.ts +166 -0
- package/tests/socketio-client.test.ts +196 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
function buildMockConn() {
|
|
4
|
+
return {
|
|
5
|
+
initialize: vi.fn<[unknown], Promise<{ protocolVersion: number }>>().mockResolvedValue({ protocolVersion: 1 }),
|
|
6
|
+
newSession: vi.fn<[unknown], Promise<{ sessionId: string }>>().mockResolvedValue({ sessionId: 'acp-sess-abc' }),
|
|
7
|
+
resumeSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
8
|
+
prompt: vi.fn<[unknown], Promise<{ stopReason: string }>>().mockResolvedValue({ stopReason: 'end_turn' }),
|
|
9
|
+
cancel: vi.fn<[unknown], Promise<void>>().mockResolvedValue(undefined),
|
|
10
|
+
closeSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
11
|
+
setSessionMode: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
12
|
+
unstable_setSessionModel: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let mockConnInstance = buildMockConn();
|
|
17
|
+
|
|
18
|
+
vi.mock('@agentclientprotocol/sdk', () => ({
|
|
19
|
+
ndJsonStream: vi.fn(() => ({})),
|
|
20
|
+
ClientSideConnection: vi.fn(function () { return mockConnInstance; }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { AcpSessionBridge } from '../../src/acp/bridge.js';
|
|
24
|
+
|
|
25
|
+
class MockAcpSpawner {
|
|
26
|
+
start = vi.fn();
|
|
27
|
+
stop = vi.fn();
|
|
28
|
+
isRunning = vi.fn().mockReturnValue(true);
|
|
29
|
+
_stdin = new WritableStream();
|
|
30
|
+
_stdout = new ReadableStream();
|
|
31
|
+
get stdin() { return this._stdin; }
|
|
32
|
+
get stdout() { return this._stdout; }
|
|
33
|
+
get closed() { return Promise.resolve(); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createMockSocket() {
|
|
37
|
+
return { emit: vi.fn(), on: vi.fn(), off: vi.fn() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mc() { return mockConnInstance; }
|
|
41
|
+
|
|
42
|
+
describe('AcpSessionBridge', () => {
|
|
43
|
+
let mockSocket: ReturnType<typeof createMockSocket>;
|
|
44
|
+
let mockSpawner: MockAcpSpawner;
|
|
45
|
+
|
|
46
|
+
function createBridge() {
|
|
47
|
+
return new AcpSessionBridge(
|
|
48
|
+
mockSocket as never,
|
|
49
|
+
{ sessionId: 'sess-1', cwd: '/home/user' },
|
|
50
|
+
mockSpawner as never,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
mockConnInstance = buildMockConn();
|
|
58
|
+
|
|
59
|
+
mockSocket = createMockSocket();
|
|
60
|
+
mockSpawner = new MockAcpSpawner();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.useRealTimers();
|
|
65
|
+
vi.restoreAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('start', () => {
|
|
69
|
+
it('should start the spawner and initialize connection', async () => {
|
|
70
|
+
const bridge = createBridge();
|
|
71
|
+
bridge.start();
|
|
72
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
73
|
+
|
|
74
|
+
expect(mockSpawner.start).toHaveBeenCalledOnce();
|
|
75
|
+
expect(mc().initialize).toHaveBeenCalledWith({
|
|
76
|
+
protocolVersion: 1,
|
|
77
|
+
clientCapabilities: {},
|
|
78
|
+
clientInfo: { name: 'agent-gear', version: '1.0.0' },
|
|
79
|
+
});
|
|
80
|
+
expect(mc().newSession).toHaveBeenCalledWith({
|
|
81
|
+
cwd: '/home/user',
|
|
82
|
+
mcpServers: [],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should emit acp:initialized after successful start', async () => {
|
|
87
|
+
const bridge = createBridge();
|
|
88
|
+
const emitSpy = vi.fn();
|
|
89
|
+
bridge.on('acp:initialized', emitSpy);
|
|
90
|
+
|
|
91
|
+
bridge.start();
|
|
92
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
93
|
+
|
|
94
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
95
|
+
sessionId: 'sess-1',
|
|
96
|
+
acpSessionId: 'acp-sess-abc',
|
|
97
|
+
}));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should include modes in acp:initialized when available', async () => {
|
|
101
|
+
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
102
|
+
sessionId: 'acp-sess-abc',
|
|
103
|
+
modes: {
|
|
104
|
+
availableModes: [{ id: 'mode-1', name: 'Code', description: 'Coding mode' }],
|
|
105
|
+
currentModeId: 'mode-1',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const bridge = createBridge();
|
|
109
|
+
const emitSpy = vi.fn();
|
|
110
|
+
bridge.on('acp:initialized', emitSpy);
|
|
111
|
+
|
|
112
|
+
bridge.start();
|
|
113
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
114
|
+
|
|
115
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
116
|
+
modes: [{ id: 'mode-1', name: 'Code', description: 'Coding mode' }],
|
|
117
|
+
currentModeId: 'mode-1',
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should include currentModel in acp:initialized when available', async () => {
|
|
122
|
+
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
123
|
+
sessionId: 'acp-sess-abc',
|
|
124
|
+
models: {
|
|
125
|
+
availableModels: [{ modelId: 'gpt-4', name: 'GPT-4' }],
|
|
126
|
+
currentModelId: 'gpt-4',
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const bridge = createBridge();
|
|
130
|
+
const emitSpy = vi.fn();
|
|
131
|
+
bridge.on('acp:initialized', emitSpy);
|
|
132
|
+
|
|
133
|
+
bridge.start();
|
|
134
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
135
|
+
|
|
136
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
137
|
+
currentModel: { id: 'gpt-4', name: 'GPT-4' },
|
|
138
|
+
}));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should include availableModels and currentModelId in acp:initialized', async () => {
|
|
142
|
+
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
143
|
+
sessionId: 'acp-sess-abc',
|
|
144
|
+
models: {
|
|
145
|
+
availableModels: [
|
|
146
|
+
{ modelId: 'gpt-4', name: 'GPT-4' },
|
|
147
|
+
{ modelId: 'claude-3', name: 'Claude 3' },
|
|
148
|
+
],
|
|
149
|
+
currentModelId: 'claude-3',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const bridge = createBridge();
|
|
153
|
+
const emitSpy = vi.fn();
|
|
154
|
+
bridge.on('acp:initialized', emitSpy);
|
|
155
|
+
|
|
156
|
+
bridge.start();
|
|
157
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
158
|
+
|
|
159
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
160
|
+
availableModels: [
|
|
161
|
+
{ id: 'gpt-4', name: 'GPT-4' },
|
|
162
|
+
{ id: 'claude-3', name: 'Claude 3' },
|
|
163
|
+
],
|
|
164
|
+
currentModelId: 'claude-3',
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should emit acp:stopped on initialize failure', async () => {
|
|
169
|
+
mc().initialize.mockRejectedValue(new Error('init failed'));
|
|
170
|
+
const bridge = createBridge();
|
|
171
|
+
|
|
172
|
+
bridge.start();
|
|
173
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
|
+
|
|
175
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', {
|
|
176
|
+
sessionId: 'sess-1',
|
|
177
|
+
reason: 'error',
|
|
178
|
+
error: 'init failed',
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should not start concurrently', async () => {
|
|
183
|
+
const bridge = createBridge();
|
|
184
|
+
bridge.start();
|
|
185
|
+
bridge.start(); // concurrent call while first is still running
|
|
186
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
187
|
+
|
|
188
|
+
expect(mockSpawner.start).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('resume', () => {
|
|
193
|
+
it('should resume an existing ACP session', async () => {
|
|
194
|
+
const bridge = createBridge();
|
|
195
|
+
const emitSpy = vi.fn();
|
|
196
|
+
bridge.on('acp:initialized', emitSpy);
|
|
197
|
+
|
|
198
|
+
bridge.resume('existing-sess-id');
|
|
199
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
200
|
+
|
|
201
|
+
expect(mc().resumeSession).toHaveBeenCalledWith({
|
|
202
|
+
sessionId: 'existing-sess-id',
|
|
203
|
+
cwd: '/home/user',
|
|
204
|
+
});
|
|
205
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
206
|
+
sessionId: 'sess-1',
|
|
207
|
+
acpSessionId: 'existing-sess-id',
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should fallback to newSession when resumeSession fails', async () => {
|
|
212
|
+
mc().resumeSession.mockRejectedValue(new Error('not supported'));
|
|
213
|
+
const bridge = createBridge();
|
|
214
|
+
const emitSpy = vi.fn();
|
|
215
|
+
bridge.on('acp:initialized', emitSpy);
|
|
216
|
+
|
|
217
|
+
bridge.resume('existing-sess-id');
|
|
218
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
219
|
+
|
|
220
|
+
expect(mc().newSession).toHaveBeenCalledWith({
|
|
221
|
+
cwd: '/home/user',
|
|
222
|
+
mcpServers: [],
|
|
223
|
+
});
|
|
224
|
+
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
225
|
+
sessionId: 'sess-1',
|
|
226
|
+
}));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should emit acp:stopped when both resume and fallback fail', async () => {
|
|
230
|
+
mc().resumeSession.mockRejectedValue(new Error('not supported'));
|
|
231
|
+
mc().newSession.mockRejectedValue(new Error('new also failed'));
|
|
232
|
+
const bridge = createBridge();
|
|
233
|
+
|
|
234
|
+
bridge.resume('existing-sess-id');
|
|
235
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
236
|
+
|
|
237
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', {
|
|
238
|
+
sessionId: 'sess-1',
|
|
239
|
+
reason: 'error',
|
|
240
|
+
error: 'new also failed',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('cancel', () => {
|
|
246
|
+
it('should call connection.cancel', async () => {
|
|
247
|
+
const bridge = createBridge();
|
|
248
|
+
bridge.start();
|
|
249
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
250
|
+
|
|
251
|
+
bridge.cancel();
|
|
252
|
+
|
|
253
|
+
expect(mc().cancel).toHaveBeenCalledWith({
|
|
254
|
+
sessionId: 'acp-sess-abc',
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should not throw when not initialized', () => {
|
|
259
|
+
const bridge = createBridge();
|
|
260
|
+
expect(() => bridge.cancel()).not.toThrow();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('stop', () => {
|
|
265
|
+
it('should cancel, close session, and stop spawner', async () => {
|
|
266
|
+
const bridge = createBridge();
|
|
267
|
+
bridge.start();
|
|
268
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
269
|
+
|
|
270
|
+
bridge.stop();
|
|
271
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
272
|
+
|
|
273
|
+
expect(mc().cancel).toHaveBeenCalled();
|
|
274
|
+
expect(mc().closeSession).toHaveBeenCalledWith({
|
|
275
|
+
sessionId: 'acp-sess-abc',
|
|
276
|
+
});
|
|
277
|
+
expect(mockSpawner.stop).toHaveBeenCalledOnce();
|
|
278
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', {
|
|
279
|
+
sessionId: 'sess-1',
|
|
280
|
+
reason: 'user_close',
|
|
281
|
+
exitCode: undefined,
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should not throw when not initialized', () => {
|
|
286
|
+
const bridge = createBridge();
|
|
287
|
+
expect(() => bridge.stop()).not.toThrow();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('sendInput', () => {
|
|
292
|
+
it('should call connection.prompt', async () => {
|
|
293
|
+
const bridge = createBridge();
|
|
294
|
+
bridge.start();
|
|
295
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
296
|
+
|
|
297
|
+
bridge.sendInput({ type: 'text', content: 'Hello' });
|
|
298
|
+
|
|
299
|
+
expect(mc().prompt).toHaveBeenCalledWith({
|
|
300
|
+
sessionId: 'acp-sess-abc',
|
|
301
|
+
prompt: [{ type: 'text', text: 'Hello' }],
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should queue input when prompt is active', async () => {
|
|
306
|
+
const bridge = createBridge();
|
|
307
|
+
bridge.start();
|
|
308
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
309
|
+
|
|
310
|
+
mc().prompt.mockReturnValue(new Promise(() => {}));
|
|
311
|
+
bridge.sendInput({ type: 'text', content: 'First' });
|
|
312
|
+
bridge.sendInput({ type: 'text', content: 'Second' });
|
|
313
|
+
|
|
314
|
+
expect(mc().prompt).toHaveBeenCalledTimes(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should process queue after prompt completes', async () => {
|
|
318
|
+
const bridge = createBridge();
|
|
319
|
+
bridge.start();
|
|
320
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
321
|
+
|
|
322
|
+
mc().prompt
|
|
323
|
+
.mockResolvedValueOnce({ stopReason: 'end_turn' })
|
|
324
|
+
.mockResolvedValueOnce({ stopReason: 'end_turn' });
|
|
325
|
+
|
|
326
|
+
bridge.sendInput({ type: 'text', content: 'First' });
|
|
327
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
328
|
+
|
|
329
|
+
bridge.sendInput({ type: 'text', content: 'Second' });
|
|
330
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
331
|
+
|
|
332
|
+
expect(mc().prompt).toHaveBeenCalledTimes(2);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('sessionUpdate handling', () => {
|
|
337
|
+
it('should buffer text chunks and flush after 50ms', async () => {
|
|
338
|
+
const bridge = createBridge();
|
|
339
|
+
|
|
340
|
+
await bridge.sessionUpdate({
|
|
341
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hi ' } },
|
|
342
|
+
} as never);
|
|
343
|
+
await bridge.sessionUpdate({
|
|
344
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'agent' } },
|
|
345
|
+
} as never);
|
|
346
|
+
|
|
347
|
+
expect(mockSocket.emit).not.toHaveBeenCalled();
|
|
348
|
+
vi.advanceTimersByTime(50);
|
|
349
|
+
|
|
350
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:message', {
|
|
351
|
+
sessionId: 'sess-1',
|
|
352
|
+
message: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hi agent' } },
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should flush text buffer on tool_call', async () => {
|
|
357
|
+
const bridge = createBridge();
|
|
358
|
+
|
|
359
|
+
await bridge.sessionUpdate({
|
|
360
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'searching' } },
|
|
361
|
+
} as never);
|
|
362
|
+
await bridge.sessionUpdate({
|
|
363
|
+
update: { sessionUpdate: 'tool_call', content: { toolName: 'search' } },
|
|
364
|
+
} as never);
|
|
365
|
+
|
|
366
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:message', {
|
|
367
|
+
sessionId: 'sess-1',
|
|
368
|
+
message: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'searching' } },
|
|
369
|
+
});
|
|
370
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:message', {
|
|
371
|
+
sessionId: 'sess-1',
|
|
372
|
+
message: { sessionUpdate: 'tool_call', content: { toolName: 'search' } },
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should ignore user_message_chunk', async () => {
|
|
377
|
+
const bridge = createBridge();
|
|
378
|
+
|
|
379
|
+
await bridge.sessionUpdate({
|
|
380
|
+
update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'user input' } },
|
|
381
|
+
} as never);
|
|
382
|
+
|
|
383
|
+
const acpMessageCalls = mockSocket.emit.mock.calls.filter(
|
|
384
|
+
(call: unknown[]) => call[0] === 'acp:message',
|
|
385
|
+
);
|
|
386
|
+
expect(acpMessageCalls).toHaveLength(0);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('requestPermission', () => {
|
|
391
|
+
it('should auto-accept with allow_once', async () => {
|
|
392
|
+
const bridge = createBridge();
|
|
393
|
+
const result = await bridge.requestPermission({
|
|
394
|
+
options: [
|
|
395
|
+
{ optionId: 'opt-1', kind: 'allow_once', label: 'Allow' },
|
|
396
|
+
{ optionId: 'opt-2', kind: 'reject', label: 'Reject' },
|
|
397
|
+
],
|
|
398
|
+
} as never);
|
|
399
|
+
|
|
400
|
+
expect(result).toEqual({
|
|
401
|
+
outcome: { outcome: 'selected', optionId: 'opt-1' },
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('setMode', () => {
|
|
407
|
+
it('should call connection.setSessionMode', async () => {
|
|
408
|
+
const bridge = createBridge();
|
|
409
|
+
bridge.start();
|
|
410
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
411
|
+
|
|
412
|
+
await bridge.setMode('mode-2');
|
|
413
|
+
|
|
414
|
+
expect(mc().setSessionMode).toHaveBeenCalledWith({
|
|
415
|
+
sessionId: 'acp-sess-abc',
|
|
416
|
+
modeId: 'mode-2',
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should not throw when not initialized', async () => {
|
|
421
|
+
const bridge = createBridge();
|
|
422
|
+
|
|
423
|
+
await expect(bridge.setMode('mode-1')).resolves.toBeUndefined();
|
|
424
|
+
expect(mc().setSessionMode).not.toHaveBeenCalled();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('setModel', () => {
|
|
429
|
+
it('should call connection.unstable_setSessionModel with correct params', async () => {
|
|
430
|
+
const bridge = createBridge();
|
|
431
|
+
bridge.start();
|
|
432
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
433
|
+
|
|
434
|
+
await bridge.setModel('model-2');
|
|
435
|
+
|
|
436
|
+
expect(mc().unstable_setSessionModel).toHaveBeenCalledWith({
|
|
437
|
+
sessionId: 'acp-sess-abc',
|
|
438
|
+
modelId: 'model-2',
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should not throw when not initialized', async () => {
|
|
443
|
+
const bridge = createBridge();
|
|
444
|
+
|
|
445
|
+
await expect(bridge.setModel('model-1')).resolves.toBeUndefined();
|
|
446
|
+
expect(mc().unstable_setSessionModel).not.toHaveBeenCalled();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('extractAvailableModels', () => {
|
|
451
|
+
it('should return null when models is null', () => {
|
|
452
|
+
const bridge = createBridge();
|
|
453
|
+
|
|
454
|
+
// Access private method via bracket notation
|
|
455
|
+
const result = (bridge as unknown as Record<string, unknown>).extractAvailableModels(null);
|
|
456
|
+
expect(result).toBeNull();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should return null when models is undefined', () => {
|
|
460
|
+
const bridge = createBridge();
|
|
461
|
+
|
|
462
|
+
const result = (bridge as unknown as Record<string, unknown>).extractAvailableModels(undefined);
|
|
463
|
+
expect(result).toBeNull();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should return null when availableModels is missing', () => {
|
|
467
|
+
const bridge = createBridge();
|
|
468
|
+
|
|
469
|
+
const result = (bridge as unknown as Record<string, unknown>).extractAvailableModels({});
|
|
470
|
+
expect(result).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('child_process', () => ({
|
|
4
|
+
exec: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { AcpDetector } from '../../src/acp/detector.js';
|
|
8
|
+
import { exec } from 'child_process';
|
|
9
|
+
|
|
10
|
+
function mockExecSuccess(stdout: string) {
|
|
11
|
+
vi.mocked(exec).mockImplementation((...args: unknown[]) => {
|
|
12
|
+
const cb = args.find((a) => typeof a === 'function') as ((err: null, result: { stdout: string; stderr: string }) => void) | undefined;
|
|
13
|
+
if (cb) cb(null, { stdout, stderr: '' });
|
|
14
|
+
return {} as any;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mockExecFailure() {
|
|
19
|
+
vi.mocked(exec).mockImplementation((...args: unknown[]) => {
|
|
20
|
+
const cb = args.find((a) => typeof a === 'function') as ((err: Error) => void) | undefined;
|
|
21
|
+
if (cb) cb(new Error('command not found'));
|
|
22
|
+
return {} as any;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('AcpDetector', () => {
|
|
27
|
+
let detector: AcpDetector;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
detector = new AcpDetector();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('detect', () => {
|
|
35
|
+
it('should detect opencode when available', async () => {
|
|
36
|
+
mockExecSuccess('opencode v1.2.3\n');
|
|
37
|
+
|
|
38
|
+
const result = await detector.detect();
|
|
39
|
+
|
|
40
|
+
expect(result).toHaveLength(1);
|
|
41
|
+
expect(result[0]).toEqual({ type: 'opencode', available: true, version: '1.2.3' });
|
|
42
|
+
expect(exec).toHaveBeenCalledWith('opencode -v', expect.any(Function));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should detect opencode as unavailable when command fails', async () => {
|
|
46
|
+
mockExecFailure();
|
|
47
|
+
|
|
48
|
+
const result = await detector.detect();
|
|
49
|
+
|
|
50
|
+
expect(result).toHaveLength(1);
|
|
51
|
+
expect(result[0]).toEqual({ type: 'opencode', available: false });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should parse version with different formats', async () => {
|
|
55
|
+
mockExecSuccess('OpenCode version 2.0.1\n');
|
|
56
|
+
|
|
57
|
+
const result = await detector.detect();
|
|
58
|
+
|
|
59
|
+
expect(result[0].version).toBe('2.0.1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return unknown version when format not recognized', async () => {
|
|
63
|
+
mockExecSuccess('some output without version\n');
|
|
64
|
+
|
|
65
|
+
const result = await detector.detect();
|
|
66
|
+
|
|
67
|
+
expect(result[0].version).toBe('unknown');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { Writable, Readable } from 'node:stream';
|
|
4
|
+
import type { ChildProcess } from 'child_process';
|
|
5
|
+
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
execFileSync: vi.fn(() => '/usr/bin/opencode'),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { AcpSpawner } from '../../src/acp/spawner.js';
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
|
|
14
|
+
function createMockChildProcess(): ChildProcess & EventEmitter {
|
|
15
|
+
const proc = new EventEmitter() as ChildProcess & EventEmitter;
|
|
16
|
+
const stdinMock = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
|
17
|
+
const stdoutMock = new Readable({ read() {} });
|
|
18
|
+
|
|
19
|
+
Object.defineProperty(proc, 'stdin', { value: stdinMock, writable: true });
|
|
20
|
+
Object.defineProperty(proc, 'stdout', { value: stdoutMock, writable: true });
|
|
21
|
+
Object.defineProperty(proc, 'stderr', { value: new Readable({ read() {} }), writable: true });
|
|
22
|
+
Object.defineProperty(proc, 'kill', { value: vi.fn().mockReturnValue(true), writable: true });
|
|
23
|
+
return proc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('AcpSpawner', () => {
|
|
27
|
+
let spawner: AcpSpawner;
|
|
28
|
+
let mockProcess: ChildProcess & EventEmitter;
|
|
29
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
vi.useFakeTimers();
|
|
34
|
+
mockProcess = createMockChildProcess();
|
|
35
|
+
vi.mocked(spawn).mockReturnValue(mockProcess as never);
|
|
36
|
+
|
|
37
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
38
|
+
|
|
39
|
+
spawner = new AcpSpawner({ sessionId: 'sess-1', cwd: '/tmp/project' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('start', () => {
|
|
48
|
+
it('should spawn opencode acp process with correct arguments', () => {
|
|
49
|
+
spawner.start();
|
|
50
|
+
expect(spawn).toHaveBeenCalledWith('/usr/bin/opencode', ['acp'], {
|
|
51
|
+
cwd: '/tmp/project',
|
|
52
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should log stderr output', () => {
|
|
57
|
+
spawner.start();
|
|
58
|
+
const stderr = mockProcess.stderr as Readable;
|
|
59
|
+
stderr.emit('data', Buffer.from('some warning\n'));
|
|
60
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('ACP stderr: some warning\n');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should resolve closed on process exit', async () => {
|
|
64
|
+
spawner.start();
|
|
65
|
+
mockProcess.emit('exit', 0, null);
|
|
66
|
+
await expect(spawner.closed).resolves.toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('stdin', () => {
|
|
71
|
+
it('should return a WritableStream when process is running', () => {
|
|
72
|
+
spawner.start();
|
|
73
|
+
const stream = spawner.stdin;
|
|
74
|
+
expect(stream).toBeInstanceOf(WritableStream);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should throw when process not running', () => {
|
|
78
|
+
expect(() => spawner.stdin).toThrow('ACP process not running');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('stdout', () => {
|
|
83
|
+
it('should return a ReadableStream when process is running', () => {
|
|
84
|
+
spawner.start();
|
|
85
|
+
const stream = spawner.stdout;
|
|
86
|
+
expect(stream).toBeInstanceOf(ReadableStream);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should throw when process not running', () => {
|
|
90
|
+
expect(() => spawner.stdout).toThrow('ACP process not running');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('stop', () => {
|
|
95
|
+
it('should send SIGTERM to process', () => {
|
|
96
|
+
spawner.start();
|
|
97
|
+
spawner.stop();
|
|
98
|
+
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should send SIGKILL after 5s if process has not exited', () => {
|
|
102
|
+
spawner.start();
|
|
103
|
+
spawner.stop();
|
|
104
|
+
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
|
105
|
+
vi.advanceTimersByTime(5000);
|
|
106
|
+
expect(mockProcess.kill).toHaveBeenCalledWith('SIGKILL');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should not send SIGKILL if process already exited', () => {
|
|
110
|
+
spawner.start();
|
|
111
|
+
spawner.stop();
|
|
112
|
+
mockProcess.emit('exit', 0, null);
|
|
113
|
+
vi.advanceTimersByTime(5000);
|
|
114
|
+
expect(mockProcess.kill).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle stop when no process', () => {
|
|
118
|
+
expect(() => spawner.stop()).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('isRunning', () => {
|
|
123
|
+
it('should return false before start', () => {
|
|
124
|
+
expect(spawner.isRunning()).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return true after start', () => {
|
|
128
|
+
spawner.start();
|
|
129
|
+
expect(spawner.isRunning()).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return false after exit', () => {
|
|
133
|
+
spawner.start();
|
|
134
|
+
mockProcess.emit('exit', 0, null);
|
|
135
|
+
expect(spawner.isRunning()).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('closed', () => {
|
|
140
|
+
it('should resolve when process exits', async () => {
|
|
141
|
+
spawner.start();
|
|
142
|
+
let resolved = false;
|
|
143
|
+
spawner.closed.then(() => { resolved = true; });
|
|
144
|
+
expect(resolved).toBe(false);
|
|
145
|
+
mockProcess.emit('exit', 0, null);
|
|
146
|
+
await vi.waitFor(() => expect(resolved).toBe(true));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|