agent-gear 1.1.9 → 1.2.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/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +11 -2
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +29 -0
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/acp/process-registry.d.ts +30 -0
- package/dist/acp/process-registry.d.ts.map +1 -0
- package/dist/acp/process-registry.integration.test.d.ts +2 -0
- package/dist/acp/process-registry.integration.test.d.ts.map +1 -0
- package/dist/acp/process-registry.integration.test.js +52 -0
- package/dist/acp/process-registry.integration.test.js.map +1 -0
- package/dist/acp/process-registry.js +110 -0
- package/dist/acp/process-registry.js.map +1 -0
- package/dist/acp/process-registry.test.d.ts +2 -0
- package/dist/acp/process-registry.test.d.ts.map +1 -0
- package/dist/acp/process-registry.test.js +209 -0
- package/dist/acp/process-registry.test.js.map +1 -0
- package/dist/acp/spawner.d.ts +1 -0
- package/dist/acp/spawner.d.ts.map +1 -1
- package/dist/acp/spawner.js +32 -10
- package/dist/acp/spawner.js.map +1 -1
- package/dist/fs-handler.d.ts +11 -0
- package/dist/fs-handler.d.ts.map +1 -1
- package/dist/fs-handler.js +79 -2
- package/dist/fs-handler.js.map +1 -1
- package/dist/fs-handler.test.js +78 -1
- package/dist/fs-handler.test.js.map +1 -1
- package/dist/index.js +32 -2
- package/dist/index.js.map +1 -1
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +177 -4
- package/dist/socketio-client.js.map +1 -1
- package/dist/worktree-handler.d.ts +12 -1
- package/dist/worktree-handler.d.ts.map +1 -1
- package/dist/worktree-handler.js +28 -2
- package/dist/worktree-handler.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +37 -0
- package/src/acp/bridge.ts +11 -2
- package/src/acp/process-registry.integration.test.ts +65 -0
- package/src/acp/process-registry.test.ts +239 -0
- package/src/acp/process-registry.ts +139 -0
- package/src/acp/spawner.ts +26 -10
- package/src/fs-handler.test.ts +106 -2
- package/src/fs-handler.ts +88 -3
- package/src/index.ts +27 -2
- package/src/socketio-client.ts +176 -5
- package/src/worktree-handler.ts +41 -2
- package/tests/acp-spawner.test.ts +58 -26
- package/tests/fs-handler.test.ts +63 -0
- package/tests/socketio-client.test.ts +288 -0
|
@@ -11,6 +11,7 @@ vi.mock('os', () => ({
|
|
|
11
11
|
vi.mock('child_process', () => ({
|
|
12
12
|
spawn: vi.fn(),
|
|
13
13
|
execFileSync: vi.fn(() => '/usr/bin/opencode'),
|
|
14
|
+
execFile: vi.fn(),
|
|
14
15
|
}));
|
|
15
16
|
|
|
16
17
|
vi.mock('fs', async (importOriginal) => {
|
|
@@ -19,7 +20,7 @@ vi.mock('fs', async (importOriginal) => {
|
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
import { AcpSpawner } from '../../src/acp/spawner.js';
|
|
22
|
-
import { spawn, execFileSync } from 'child_process';
|
|
23
|
+
import { spawn, execFileSync, execFile } from 'child_process';
|
|
23
24
|
import { existsSync } from 'fs';
|
|
24
25
|
|
|
25
26
|
function createMockChildProcess(): ChildProcess & EventEmitter {
|
|
@@ -31,6 +32,7 @@ function createMockChildProcess(): ChildProcess & EventEmitter {
|
|
|
31
32
|
Object.defineProperty(proc, 'stdout', { value: stdoutMock, writable: true });
|
|
32
33
|
Object.defineProperty(proc, 'stderr', { value: new Readable({ read() {} }), writable: true });
|
|
33
34
|
Object.defineProperty(proc, 'kill', { value: vi.fn().mockReturnValue(true), writable: true });
|
|
35
|
+
Object.defineProperty(proc, 'pid', { value: 12345, writable: true });
|
|
34
36
|
return proc;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -60,6 +62,7 @@ describe('AcpSpawner', () => {
|
|
|
60
62
|
spawner.start();
|
|
61
63
|
expect(spawn).toHaveBeenCalledWith('/usr/bin/opencode', ['acp'], {
|
|
62
64
|
cwd: '/tmp/project',
|
|
65
|
+
detached: true,
|
|
63
66
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
67
|
});
|
|
65
68
|
});
|
|
@@ -130,26 +133,39 @@ describe('AcpSpawner', () => {
|
|
|
130
133
|
});
|
|
131
134
|
|
|
132
135
|
describe('stop', () => {
|
|
133
|
-
|
|
136
|
+
let processKillSpy: ReturnType<typeof vi.spyOn>;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true as any);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
processKillSpy.mockRestore();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should send SIGTERM to process group', () => {
|
|
134
147
|
spawner.start();
|
|
148
|
+
const pid = mockProcess.pid;
|
|
135
149
|
spawner.stop();
|
|
136
|
-
expect(
|
|
150
|
+
expect(processKillSpy).toHaveBeenCalledWith(-pid, 'SIGTERM');
|
|
137
151
|
});
|
|
138
152
|
|
|
139
153
|
it('should send SIGKILL after 5s if process has not exited', () => {
|
|
140
154
|
spawner.start();
|
|
155
|
+
const pid = mockProcess.pid;
|
|
141
156
|
spawner.stop();
|
|
142
|
-
expect(
|
|
157
|
+
expect(processKillSpy).toHaveBeenCalledWith(-pid, 'SIGTERM');
|
|
143
158
|
vi.advanceTimersByTime(5000);
|
|
144
|
-
expect(
|
|
159
|
+
expect(processKillSpy).toHaveBeenCalledWith(-pid, 'SIGKILL');
|
|
145
160
|
});
|
|
146
161
|
|
|
147
162
|
it('should not send SIGKILL if process already exited', () => {
|
|
148
163
|
spawner.start();
|
|
164
|
+
const pid = mockProcess.pid;
|
|
149
165
|
spawner.stop();
|
|
150
166
|
mockProcess.emit('exit', 0, null);
|
|
151
167
|
vi.advanceTimersByTime(5000);
|
|
152
|
-
expect(
|
|
168
|
+
expect(processKillSpy).not.toHaveBeenCalledWith(-pid, 'SIGKILL');
|
|
153
169
|
});
|
|
154
170
|
|
|
155
171
|
it('should handle stop when no process', () => {
|
|
@@ -205,14 +221,14 @@ describe('AcpSpawner', () => {
|
|
|
205
221
|
expect(execFileSync).toHaveBeenCalledWith('where', ['copilot'], { encoding: 'utf8', timeout: 1000 });
|
|
206
222
|
});
|
|
207
223
|
|
|
208
|
-
it('should return full path from `where`', () => {
|
|
224
|
+
it('should return full path from `where` and use shell for .cmd', () => {
|
|
209
225
|
vi.mocked(execFileSync).mockReturnValue('C:\\Users\\ash\\AppData\\Roaming\\npm\\copilot.cmd\r\n');
|
|
210
226
|
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot', args: ['--acp', '--stdio'] });
|
|
211
227
|
sp.start();
|
|
212
228
|
expect(spawn).toHaveBeenCalledWith(
|
|
213
229
|
'C:\\Users\\ash\\AppData\\Roaming\\npm\\copilot.cmd',
|
|
214
230
|
['--acp', '--stdio'],
|
|
215
|
-
expect.objectContaining({ shell: true }),
|
|
231
|
+
expect.objectContaining({ cwd: 'C:\\project', stdio: ['pipe', 'pipe', 'pipe'], shell: true }),
|
|
216
232
|
);
|
|
217
233
|
});
|
|
218
234
|
|
|
@@ -221,7 +237,7 @@ describe('AcpSpawner', () => {
|
|
|
221
237
|
vi.mocked(existsSync).mockImplementation((p: string) => p === 'copilot.cmd');
|
|
222
238
|
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
|
|
223
239
|
sp.start();
|
|
224
|
-
expect(spawn).toHaveBeenCalledWith('copilot.cmd', expect.any(Array), expect.objectContaining({
|
|
240
|
+
expect(spawn).toHaveBeenCalledWith('copilot.cmd', expect.any(Array), expect.objectContaining({ cwd: 'C:\\project' }));
|
|
225
241
|
});
|
|
226
242
|
|
|
227
243
|
it('should return bare command as last resort', () => {
|
|
@@ -229,42 +245,58 @@ describe('AcpSpawner', () => {
|
|
|
229
245
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
230
246
|
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
|
|
231
247
|
sp.start();
|
|
232
|
-
expect(spawn).toHaveBeenCalledWith('copilot', expect.any(Array), expect.objectContaining({
|
|
248
|
+
expect(spawn).toHaveBeenCalledWith('copilot', expect.any(Array), expect.objectContaining({ cwd: 'C:\\project' }));
|
|
233
249
|
});
|
|
234
250
|
});
|
|
235
251
|
|
|
236
252
|
describe('start', () => {
|
|
237
|
-
it('should spawn with shell: true', () => {
|
|
253
|
+
it('should spawn .cmd with shell: true on Windows (Node EINVAL on shell-less .cmd)', () => {
|
|
238
254
|
vi.mocked(execFileSync).mockReturnValue('C:\\npm\\copilot.cmd\r\n');
|
|
239
255
|
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
|
|
240
256
|
sp.start();
|
|
241
257
|
expect(spawn).toHaveBeenCalledWith(
|
|
242
|
-
|
|
258
|
+
'C:\\npm\\copilot.cmd',
|
|
243
259
|
expect.any(Array),
|
|
244
|
-
expect.objectContaining({ shell: true }),
|
|
260
|
+
expect.objectContaining({ cwd: 'C:\\project', shell: true }),
|
|
245
261
|
);
|
|
246
262
|
});
|
|
247
|
-
});
|
|
248
263
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: '
|
|
252
|
-
vi.mocked(execFileSync).mockReturnValue('C:\\npm\\copilot.cmd\r\n');
|
|
264
|
+
it('should spawn .bat with shell: true on Windows', () => {
|
|
265
|
+
vi.mocked(execFileSync).mockReturnValue('C:\\npm\\agent.bat\r\n');
|
|
266
|
+
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'agent' });
|
|
253
267
|
sp.start();
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
268
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
269
|
+
'C:\\npm\\agent.bat',
|
|
270
|
+
expect.any(Array),
|
|
271
|
+
expect.objectContaining({ cwd: 'C:\\project', shell: true }),
|
|
272
|
+
);
|
|
257
273
|
});
|
|
258
274
|
|
|
259
|
-
it('should
|
|
275
|
+
it('should spawn a real .exe directly without shell on Windows', () => {
|
|
276
|
+
vi.mocked(execFileSync).mockReturnValue('C:\\tools\\opencode.exe\r\n');
|
|
277
|
+
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'opencode' });
|
|
278
|
+
sp.start();
|
|
279
|
+
const spawnOptions = vi.mocked(spawn).mock.calls[0][2];
|
|
280
|
+
expect(spawn).toHaveBeenCalledWith('C:\\tools\\opencode.exe', expect.any(Array), expect.objectContaining({ cwd: 'C:\\project' }));
|
|
281
|
+
expect(spawnOptions).not.toHaveProperty('shell');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('stop', () => {
|
|
286
|
+
it('should use taskkill on Windows instead of signal kill', () => {
|
|
287
|
+
vi.mocked(execFile).mockImplementation((_cmd: any, _args: any, cb: any) => {
|
|
288
|
+
if (typeof cb === 'function') cb(null, '', '');
|
|
289
|
+
return {} as any;
|
|
290
|
+
});
|
|
291
|
+
const processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true as any);
|
|
260
292
|
const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
|
|
261
293
|
vi.mocked(execFileSync).mockReturnValue('C:\\npm\\copilot.cmd\r\n');
|
|
262
294
|
sp.start();
|
|
263
295
|
sp.stop();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
expect(
|
|
267
|
-
|
|
296
|
+
// On Windows, stop() should call taskkill, not POSIX signals
|
|
297
|
+
expect(processKillSpy).not.toHaveBeenCalledWith(expect.anything(), 'SIGTERM');
|
|
298
|
+
expect(processKillSpy).not.toHaveBeenCalledWith(expect.anything(), 'SIGKILL');
|
|
299
|
+
processKillSpy.mockRestore();
|
|
268
300
|
});
|
|
269
301
|
});
|
|
270
302
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { FsHandler } from '../src/fs-handler.js';
|
|
6
|
+
|
|
7
|
+
// 真实 fs 集成测试(不 mock):removeDir 的安全语义只有真实文件系统能覆盖
|
|
8
|
+
// (验收报告建议项 #31:isPathSafe/removeDir 零用例)
|
|
9
|
+
|
|
10
|
+
describe('FsHandler.removeDir(真实 fs)', () => {
|
|
11
|
+
const created: string[] = [];
|
|
12
|
+
|
|
13
|
+
function makeDir(): string {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), 'fs-handler-test-'));
|
|
15
|
+
created.push(dir);
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const dir of created.splice(0)) {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('拒绝删除 .git 为目录的路径(git 主工作区),目录保持完整', async () => {
|
|
26
|
+
const handler = new FsHandler();
|
|
27
|
+
const repoRoot = makeDir();
|
|
28
|
+
mkdirSync(join(repoRoot, '.git'));
|
|
29
|
+
writeFileSync(join(repoRoot, 'index.ts'), 'export {}');
|
|
30
|
+
handler.registerAllowedPath(repoRoot);
|
|
31
|
+
|
|
32
|
+
const result = await handler.removeDir(repoRoot);
|
|
33
|
+
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
expect(result.error).toContain('main working tree');
|
|
36
|
+
expect(existsSync(join(repoRoot, '.git'))).toBe(true);
|
|
37
|
+
expect(existsSync(join(repoRoot, 'index.ts'))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('允许删除 .git 为文件的已注册路径(linked worktree)', async () => {
|
|
41
|
+
const handler = new FsHandler();
|
|
42
|
+
const worktreeDir = makeDir();
|
|
43
|
+
// linked worktree 的 .git 是指向主仓库 gitdir 的普通文件
|
|
44
|
+
writeFileSync(join(worktreeDir, '.git'), 'gitdir: /repo/.git/worktrees/feat-1');
|
|
45
|
+
writeFileSync(join(worktreeDir, 'index.ts'), 'export {}');
|
|
46
|
+
handler.registerAllowedPath(worktreeDir);
|
|
47
|
+
|
|
48
|
+
const result = await handler.removeDir(worktreeDir);
|
|
49
|
+
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
expect(existsSync(worktreeDir)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('拒绝删除未注册的路径', async () => {
|
|
55
|
+
const handler = new FsHandler();
|
|
56
|
+
const dir = makeDir();
|
|
57
|
+
|
|
58
|
+
const result = await handler.removeDir(dir);
|
|
59
|
+
|
|
60
|
+
expect(result.success).toBe(false);
|
|
61
|
+
expect(existsSync(dir)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -28,6 +28,76 @@ vi.mock('../src/acp/sync-bridge.js', () => ({
|
|
|
28
28
|
AcpSyncBridge: MockAcpSyncBridge,
|
|
29
29
|
}));
|
|
30
30
|
|
|
31
|
+
const { bridgeInstances } = vi.hoisted(() => ({
|
|
32
|
+
bridgeInstances: [] as Array<{ sessionId: string; emit: (event: string, payload: unknown) => boolean }>,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../src/acp/bridge.js', async () => {
|
|
36
|
+
const { EventEmitter } = await import('events');
|
|
37
|
+
class FakeAcpSessionBridge extends EventEmitter {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
constructor(_socket: unknown, options: { sessionId: string }, _spawner: unknown) {
|
|
40
|
+
super();
|
|
41
|
+
this.sessionId = options.sessionId;
|
|
42
|
+
bridgeInstances.push(this as never);
|
|
43
|
+
}
|
|
44
|
+
async start() {
|
|
45
|
+
this.emit('acp:initialized', {
|
|
46
|
+
sessionId: this.sessionId,
|
|
47
|
+
acpSessionId: 'acp-ext-1',
|
|
48
|
+
modes: [],
|
|
49
|
+
availableModels: [],
|
|
50
|
+
configOptions: [],
|
|
51
|
+
authMethods: [],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async resume() {
|
|
55
|
+
this.emit('acp:initialized', {
|
|
56
|
+
sessionId: this.sessionId,
|
|
57
|
+
acpSessionId: 'acp-ext-1',
|
|
58
|
+
modes: [],
|
|
59
|
+
availableModels: [],
|
|
60
|
+
configOptions: [],
|
|
61
|
+
authMethods: [],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async stop() {
|
|
65
|
+
this.emit('acp:stopped', { sessionId: this.sessionId, reason: 'user_close' });
|
|
66
|
+
}
|
|
67
|
+
sendInput() {}
|
|
68
|
+
cancel() {}
|
|
69
|
+
}
|
|
70
|
+
return { AcpSessionBridge: FakeAcpSessionBridge };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
vi.mock('../src/acp/spawner.js', () => ({
|
|
74
|
+
AcpSpawner: class {
|
|
75
|
+
pid = 4321;
|
|
76
|
+
constructor(_options: unknown) {}
|
|
77
|
+
start() {}
|
|
78
|
+
stop() {}
|
|
79
|
+
get closed() { return Promise.resolve(); }
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
vi.mock('../src/acp/detector.js', () => ({
|
|
84
|
+
acpDetector: {
|
|
85
|
+
detect: vi.fn(async () => []),
|
|
86
|
+
getSpawnerConfig: vi.fn(() => ({ command: 'opencode', args: ['acp'] })),
|
|
87
|
+
},
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
vi.mock('../src/acp/process-registry.js', () => ({
|
|
91
|
+
processRegistry: {
|
|
92
|
+
register: vi.fn(),
|
|
93
|
+
deregister: vi.fn(),
|
|
94
|
+
get: vi.fn(),
|
|
95
|
+
list: vi.fn(() => []),
|
|
96
|
+
listEntries: vi.fn(() => []),
|
|
97
|
+
},
|
|
98
|
+
isProcessAlive: vi.fn(() => true),
|
|
99
|
+
}));
|
|
100
|
+
|
|
31
101
|
vi.mock('socket.io-client', () => ({
|
|
32
102
|
io: vi.fn(),
|
|
33
103
|
}));
|
|
@@ -66,6 +136,8 @@ import { io } from 'socket.io-client';
|
|
|
66
136
|
import { clearConfigKey } from '../src/config.js';
|
|
67
137
|
import * as os from 'os';
|
|
68
138
|
import { readdir, access } from 'fs/promises';
|
|
139
|
+
import { fsHandler } from '../src/fs-handler.js';
|
|
140
|
+
import { processRegistry, isProcessAlive } from '../src/acp/process-registry.js';
|
|
69
141
|
|
|
70
142
|
function createMockSocket() {
|
|
71
143
|
const handlers: Record<string, (...args: unknown[]) => void> = {};
|
|
@@ -527,4 +599,220 @@ describe('SocketIO Client', () => {
|
|
|
527
599
|
});
|
|
528
600
|
});
|
|
529
601
|
});
|
|
602
|
+
|
|
603
|
+
describe('acp:start registry lifecycle', () => {
|
|
604
|
+
function setup() {
|
|
605
|
+
bridgeInstances.length = 0;
|
|
606
|
+
const mockSocket = createMockSocket();
|
|
607
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
608
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
609
|
+
return mockSocket;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function startSession(mockSocket: ReturnType<typeof createMockSocket>, sessionId: string, cwd: string) {
|
|
613
|
+
mockSocket._trigger('acp:start', {
|
|
614
|
+
requestId: 'req-start',
|
|
615
|
+
sessionId,
|
|
616
|
+
serverType: 'opencode',
|
|
617
|
+
cwd,
|
|
618
|
+
});
|
|
619
|
+
await vi.waitFor(() => {
|
|
620
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId, success: true }));
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
it('registers the session cwd as an allowed fs path', async () => {
|
|
625
|
+
const registerSpy = vi.spyOn(fsHandler, 'registerAllowedPath');
|
|
626
|
+
const mockSocket = setup();
|
|
627
|
+
|
|
628
|
+
await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
|
|
629
|
+
|
|
630
|
+
expect(registerSpy).toHaveBeenCalledWith('/home/user/wt-1');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('stops the previous bridge when acp:start arrives for the same sessionId again', async () => {
|
|
634
|
+
const mockSocket = setup();
|
|
635
|
+
await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
|
|
636
|
+
const oldBridge = bridgeInstances[0] as unknown as { stop: () => Promise<void> };
|
|
637
|
+
const stopSpy = vi.spyOn(oldBridge, 'stop');
|
|
638
|
+
|
|
639
|
+
// 同 sessionId 再次 acp:start(如超时重试/兜底 resume 后迟到):旧 bridge 必须先停,
|
|
640
|
+
// 否则同会话双进程、旧 bridge 泄漏
|
|
641
|
+
mockSocket._trigger('acp:start', {
|
|
642
|
+
requestId: 'req-start-2',
|
|
643
|
+
sessionId: 'sess-1',
|
|
644
|
+
serverType: 'opencode',
|
|
645
|
+
cwd: '/home/user/wt-1',
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
await vi.waitFor(() => {
|
|
649
|
+
expect(stopSpy).toHaveBeenCalled();
|
|
650
|
+
});
|
|
651
|
+
await vi.waitFor(() => {
|
|
652
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({
|
|
653
|
+
requestId: 'req-start-2',
|
|
654
|
+
sessionId: 'sess-1',
|
|
655
|
+
success: true,
|
|
656
|
+
}));
|
|
657
|
+
});
|
|
658
|
+
expect(bridgeInstances).toHaveLength(2);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('deregisters the PID when the bridge emits acp:stopped', async () => {
|
|
662
|
+
const mockSocket = setup();
|
|
663
|
+
await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
|
|
664
|
+
vi.mocked(processRegistry.deregister).mockClear();
|
|
665
|
+
|
|
666
|
+
bridgeInstances[0].emit('acp:stopped', { sessionId: 'sess-1', reason: 'user_close' });
|
|
667
|
+
|
|
668
|
+
expect(processRegistry.deregister).toHaveBeenCalledWith('sess-1');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('deregisters the PID on the acp:stop bridge path', async () => {
|
|
672
|
+
const mockSocket = setup();
|
|
673
|
+
await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
|
|
674
|
+
vi.mocked(processRegistry.deregister).mockClear();
|
|
675
|
+
|
|
676
|
+
mockSocket._trigger('acp:stop', { sessionId: 'sess-1' });
|
|
677
|
+
|
|
678
|
+
await vi.waitFor(() => {
|
|
679
|
+
expect(processRegistry.deregister).toHaveBeenCalledWith('sess-1');
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
describe('acp:stop fallback kill', () => {
|
|
685
|
+
beforeEach(() => {
|
|
686
|
+
vi.useFakeTimers();
|
|
687
|
+
vi.mocked(os.platform).mockReturnValue('linux');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
afterEach(() => {
|
|
691
|
+
vi.useRealTimers();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
function setup() {
|
|
695
|
+
bridgeInstances.length = 0;
|
|
696
|
+
const mockSocket = createMockSocket();
|
|
697
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
698
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
699
|
+
return mockSocket;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
it('kills by registry PID and probes liveness before SIGKILL', async () => {
|
|
703
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
704
|
+
vi.mocked(processRegistry.get).mockReturnValue({ pid: 2222, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' });
|
|
705
|
+
vi.mocked(isProcessAlive).mockReturnValue(true);
|
|
706
|
+
const mockSocket = setup();
|
|
707
|
+
|
|
708
|
+
mockSocket._trigger('acp:stop', { sessionId: 'sess-x', ackRequired: true });
|
|
709
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
710
|
+
|
|
711
|
+
expect(killSpy).toHaveBeenCalledWith(-2222, 'SIGTERM');
|
|
712
|
+
expect(processRegistry.deregister).toHaveBeenCalledWith('sess-x');
|
|
713
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', {
|
|
714
|
+
sessionId: 'sess-x',
|
|
715
|
+
reason: 'user_close',
|
|
716
|
+
killed: true,
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
720
|
+
expect(isProcessAlive).toHaveBeenCalledWith(2222);
|
|
721
|
+
expect(killSpy).toHaveBeenCalledWith(-2222, 'SIGKILL');
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('skips SIGKILL when the process is already dead (PID reuse guard)', async () => {
|
|
725
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
726
|
+
vi.mocked(processRegistry.get).mockReturnValue({ pid: 2222, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' });
|
|
727
|
+
vi.mocked(isProcessAlive).mockReturnValue(false);
|
|
728
|
+
const mockSocket = setup();
|
|
729
|
+
|
|
730
|
+
mockSocket._trigger('acp:stop', { sessionId: 'sess-x' });
|
|
731
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
732
|
+
expect(killSpy).toHaveBeenCalledWith(-2222, 'SIGTERM');
|
|
733
|
+
|
|
734
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
735
|
+
expect(killSpy).not.toHaveBeenCalledWith(-2222, 'SIGKILL');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('ignores malformed payloads with a warning', async () => {
|
|
739
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
740
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
741
|
+
const mockSocket = setup();
|
|
742
|
+
|
|
743
|
+
mockSocket._trigger('acp:stop', { ackRequired: true });
|
|
744
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
745
|
+
|
|
746
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
747
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
748
|
+
expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:stopped', expect.anything());
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
describe('acp:reconcile', () => {
|
|
753
|
+
beforeEach(() => {
|
|
754
|
+
vi.useFakeTimers();
|
|
755
|
+
vi.mocked(os.platform).mockReturnValue('linux');
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
afterEach(() => {
|
|
759
|
+
vi.useRealTimers();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
function setup() {
|
|
763
|
+
bridgeInstances.length = 0;
|
|
764
|
+
const mockSocket = createMockSocket();
|
|
765
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
766
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
767
|
+
return mockSocket;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
it('kills orphan registry entries and probes liveness before SIGKILL', async () => {
|
|
771
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
772
|
+
vi.mocked(processRegistry.listEntries).mockReturnValue([
|
|
773
|
+
{ sessionId: 's1', pid: 3333, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
|
|
774
|
+
]);
|
|
775
|
+
vi.mocked(isProcessAlive).mockReturnValue(false);
|
|
776
|
+
const mockSocket = setup();
|
|
777
|
+
|
|
778
|
+
mockSocket._trigger('acp:reconcile', { activeSessionIds: [], pendingKillSessionIds: [] });
|
|
779
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
780
|
+
|
|
781
|
+
expect(killSpy).toHaveBeenCalledWith(-3333, 'SIGTERM');
|
|
782
|
+
expect(processRegistry.deregister).toHaveBeenCalledWith('s1');
|
|
783
|
+
|
|
784
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
785
|
+
expect(isProcessAlive).toHaveBeenCalledWith(3333);
|
|
786
|
+
expect(killSpy).not.toHaveBeenCalledWith(-3333, 'SIGKILL');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('keeps sessions listed in activeSessionIds', async () => {
|
|
790
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
791
|
+
vi.mocked(processRegistry.listEntries).mockReturnValue([
|
|
792
|
+
{ sessionId: 's1', pid: 3333, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
|
|
793
|
+
]);
|
|
794
|
+
const mockSocket = setup();
|
|
795
|
+
|
|
796
|
+
mockSocket._trigger('acp:reconcile', { activeSessionIds: ['s1'], pendingKillSessionIds: [] });
|
|
797
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
798
|
+
|
|
799
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
800
|
+
expect(processRegistry.deregister).not.toHaveBeenCalled();
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('ignores malformed payloads with a warning', async () => {
|
|
804
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
805
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
806
|
+
const mockSocket = setup();
|
|
807
|
+
// connectToServer seeds the fs whitelist via listEntries once; clear it
|
|
808
|
+
vi.mocked(processRegistry.listEntries).mockClear();
|
|
809
|
+
|
|
810
|
+
mockSocket._trigger('acp:reconcile', { activeSessionIds: 'oops', pendingKillSessionIds: null });
|
|
811
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
812
|
+
|
|
813
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
814
|
+
expect(processRegistry.listEntries).not.toHaveBeenCalled();
|
|
815
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
816
|
+
});
|
|
817
|
+
});
|
|
530
818
|
});
|