agent-gear 1.0.4 → 1.1.1

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/src/acp/bridge.ts CHANGED
@@ -38,6 +38,11 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
38
38
  private textChunkBuffer = '';
39
39
  private textChunkTimer: ReturnType<typeof setTimeout> | null = null;
40
40
  private initializing = false;
41
+ private stopped = false;
42
+ private pendingPermissions = new Map<string, {
43
+ resolve: (response: acp.RequestPermissionResponse) => void;
44
+ timeout: NodeJS.Timeout;
45
+ }>();
41
46
 
42
47
  constructor(socket: Socket, options: AcpBridgeOptions, spawner?: AcpSpawner) {
43
48
  super();
@@ -173,19 +178,29 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
173
178
  }
174
179
 
175
180
  private async processPrompt(message: QueuedInput): Promise<void> {
176
- if (!this.connection || !this.acpSessionId) return;
181
+ if (!this.connection || !this.acpSessionId || this.stopped) return;
177
182
 
178
183
  this.activePrompt = this.connection.prompt({
179
184
  sessionId: this.acpSessionId,
180
185
  prompt: [{ type: 'text' as const, text: message.content }],
181
186
  });
182
187
 
188
+ this.socket.emit('acp:prompt:start', { sessionId: this.sessionId });
189
+
190
+ let stopReason = 'end_turn';
183
191
  try {
184
- await this.activePrompt;
192
+ const result = await this.activePrompt;
193
+ stopReason = result?.stopReason ?? 'end_turn';
185
194
  } catch {
186
- // cancelled or error
195
+ stopReason = 'error';
187
196
  } finally {
188
197
  this.activePrompt = null;
198
+ if (!this.stopped) {
199
+ this.socket.emit('acp:prompt:complete', {
200
+ sessionId: this.sessionId,
201
+ stopReason,
202
+ });
203
+ }
189
204
  if (this.inputQueue.length > 0) {
190
205
  this.processPrompt(this.inputQueue.shift()!);
191
206
  }
@@ -267,12 +282,37 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
267
282
  async requestPermission(
268
283
  params: acp.RequestPermissionRequest,
269
284
  ): Promise<acp.RequestPermissionResponse> {
270
- return {
271
- outcome: {
272
- outcome: 'selected',
273
- optionId: params.options.find((o) => o.kind === 'allow_once')?.optionId ?? params.options[0]?.optionId ?? 'reject',
274
- },
275
- };
285
+ const requestId = `perm_${Date.now()}_${Math.random().toString(36).slice(2)}`;
286
+
287
+ return new Promise((resolve) => {
288
+ const timeout = setTimeout(() => {
289
+ this.pendingPermissions.delete(requestId);
290
+ resolve({ outcome: { outcome: 'cancelled' } });
291
+ }, 65_000);
292
+
293
+ this.pendingPermissions.set(requestId, { resolve, timeout });
294
+
295
+ this.socket.emit('acp:permission', {
296
+ requestId,
297
+ sessionId: this.sessionId,
298
+ toolCall: params.toolCall,
299
+ options: params.options,
300
+ });
301
+
302
+ this.emit('permission:sent', requestId);
303
+ });
304
+ }
305
+
306
+ handlePermissionResponse(requestId: string, outcome: acp.RequestPermissionOutcome): void {
307
+ const pending = this.pendingPermissions.get(requestId);
308
+ if (!pending) {
309
+ console.warn(`Permission request ${requestId} not found`);
310
+ return;
311
+ }
312
+
313
+ clearTimeout(pending.timeout);
314
+ this.pendingPermissions.delete(requestId);
315
+ pending.resolve({ outcome });
276
316
  }
277
317
 
278
318
  // ── text buffering ──
@@ -306,11 +346,13 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
306
346
  }
307
347
 
308
348
  private emitStopped(reason: string, exitCode?: number): void {
349
+ this.stopped = true;
309
350
  this.socket.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
310
351
  this.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
311
352
  }
312
353
 
313
354
  private handleError(error: unknown): void {
355
+ this.stopped = true;
314
356
  const msg = error instanceof Error ? error.message : String(error);
315
357
  this.socket.emit('acp:stopped', {
316
358
  sessionId: this.sessionId,
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@ export interface ConfigData {
10
10
  key?: { value: string };
11
11
  server?: { endpoint: string };
12
12
  controller?: { name: string };
13
+ resumeToken?: { value: string };
13
14
  }
14
15
 
15
16
  export function readConfig(): ConfigData {
@@ -73,3 +74,20 @@ export function resolveEndpoint(cliEndpoint?: string): string | undefined {
73
74
  if (envEndpoint) return envEndpoint;
74
75
  return getEndpointFromConfig();
75
76
  }
77
+
78
+ export function getResumeTokenFromConfig(): string | undefined {
79
+ const config = readConfig();
80
+ return config.resumeToken?.value;
81
+ }
82
+
83
+ export function saveResumeTokenToConfig(token: string): void {
84
+ const config = readConfig();
85
+ config.resumeToken = { value: token };
86
+ writeConfig(config);
87
+ }
88
+
89
+ export function clearResumeTokenFromConfig(): void {
90
+ const config = readConfig();
91
+ delete config.resumeToken;
92
+ writeConfig(config);
93
+ }
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('node:net', () => ({
4
+ default: {
5
+ connect: vi.fn(),
6
+ },
7
+ }));
8
+
9
+ import net from 'node:net';
10
+ import { PortForwardManager } from './port-forward.js';
11
+
12
+ function createMockLocalSocket() {
13
+ const handlers = new Map<string, Function>();
14
+ return {
15
+ once: vi.fn((event: string, handler: Function) => {
16
+ handlers.set(event, handler);
17
+ }),
18
+ on: vi.fn((event: string, handler: Function) => {
19
+ handlers.set(event, handler);
20
+ }),
21
+ removeListener: vi.fn(),
22
+ destroy: vi.fn(),
23
+ write: vi.fn(),
24
+ emit: vi.fn(),
25
+ _handlers: handlers,
26
+ _trigger(event: string, ...args: unknown[]) {
27
+ const handler = handlers.get(event);
28
+ if (handler) handler(...args);
29
+ },
30
+ };
31
+ }
32
+
33
+ function createMockServerSocket() {
34
+ return {
35
+ emit: vi.fn(),
36
+ };
37
+ }
38
+
39
+ describe('PortForwardManager', () => {
40
+ let portForwardManager: PortForwardManager;
41
+
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ portForwardManager = new PortForwardManager();
45
+ });
46
+
47
+ describe('createTunnel', () => {
48
+ it('should send port-forward:created(success: true) and store tunnel on connection success', async () => {
49
+ const mockLocalSocket = createMockLocalSocket();
50
+ const mockServerSocket = createMockServerSocket();
51
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
52
+
53
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
54
+ mockLocalSocket._trigger('connect');
55
+ await promise;
56
+
57
+ expect(net.connect).toHaveBeenCalledWith({ host: '127.0.0.1', port: 3000 });
58
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:created', {
59
+ tunnelId: 'tunnel-1',
60
+ success: true,
61
+ });
62
+ });
63
+
64
+ it('should send port-forward:created(success: false, error) and destroy socket on connection failure', async () => {
65
+ const mockLocalSocket = createMockLocalSocket();
66
+ const mockServerSocket = createMockServerSocket();
67
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
68
+
69
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
70
+ mockLocalSocket._trigger('error', new Error('ECONNREFUSED'));
71
+ await promise;
72
+
73
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:created', {
74
+ tunnelId: 'tunnel-1',
75
+ success: false,
76
+ error: 'ECONNREFUSED',
77
+ });
78
+ expect(mockLocalSocket.destroy).toHaveBeenCalled();
79
+ });
80
+
81
+ it('should send port-forward:data with base64 encoded data when local socket receives data', async () => {
82
+ const mockLocalSocket = createMockLocalSocket();
83
+ const mockServerSocket = createMockServerSocket();
84
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
85
+
86
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
87
+ mockLocalSocket._trigger('connect');
88
+ await promise;
89
+
90
+ const testData = Buffer.from('hello world');
91
+ mockLocalSocket._trigger('data', testData);
92
+
93
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:data', {
94
+ tunnelId: 'tunnel-1',
95
+ data: testData.toString('base64'),
96
+ });
97
+ });
98
+
99
+ it('should send port-forward:close and remove tunnel when local socket closes', async () => {
100
+ const mockLocalSocket = createMockLocalSocket();
101
+ const mockServerSocket = createMockServerSocket();
102
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
103
+
104
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
105
+ mockLocalSocket._trigger('connect');
106
+ await promise;
107
+
108
+ mockLocalSocket._trigger('close');
109
+
110
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:close', {
111
+ tunnelId: 'tunnel-1',
112
+ });
113
+ });
114
+
115
+ it('should send port-forward:error and remove tunnel when local socket errors after connect', async () => {
116
+ const mockLocalSocket = createMockLocalSocket();
117
+ const mockServerSocket = createMockServerSocket();
118
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
119
+
120
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
121
+ mockLocalSocket._trigger('connect');
122
+ await promise;
123
+
124
+ mockLocalSocket._trigger('error', new Error('ECONNRESET'));
125
+
126
+ expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:error', {
127
+ tunnelId: 'tunnel-1',
128
+ error: 'ECONNRESET',
129
+ });
130
+ });
131
+ });
132
+
133
+ describe('handleTunnelData', () => {
134
+ it('should write base64 decoded data to the corresponding local socket', async () => {
135
+ const mockLocalSocket = createMockLocalSocket();
136
+ const mockServerSocket = createMockServerSocket();
137
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
138
+
139
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
140
+ mockLocalSocket._trigger('connect');
141
+ await promise;
142
+
143
+ const base64Data = Buffer.from('test payload').toString('base64');
144
+ portForwardManager.handleTunnelData('tunnel-1', base64Data);
145
+
146
+ expect(mockLocalSocket.write).toHaveBeenCalledWith(Buffer.from('test payload'));
147
+ });
148
+
149
+ it('should log warning and ignore for unknown tunnelId', () => {
150
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
151
+ portForwardManager.handleTunnelData('unknown-tunnel', 'data');
152
+ expect(warnSpy).toHaveBeenCalledWith(
153
+ expect.stringContaining('unknown tunnel'),
154
+ );
155
+ warnSpy.mockRestore();
156
+ });
157
+ });
158
+
159
+ describe('closeTunnel', () => {
160
+ it('should destroy local socket and remove tunnel from map', async () => {
161
+ const mockLocalSocket = createMockLocalSocket();
162
+ const mockServerSocket = createMockServerSocket();
163
+ vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
164
+
165
+ const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
166
+ mockLocalSocket._trigger('connect');
167
+ await promise;
168
+
169
+ portForwardManager.closeTunnel('tunnel-1');
170
+
171
+ expect(mockLocalSocket.destroy).toHaveBeenCalled();
172
+ });
173
+
174
+ it('should silently ignore unknown tunnelId', () => {
175
+ expect(() => portForwardManager.closeTunnel('unknown-tunnel')).not.toThrow();
176
+ });
177
+ });
178
+
179
+ describe('closeAll', () => {
180
+ it('should close all tunnels and clear the map', async () => {
181
+ const socket1 = createMockLocalSocket();
182
+ const socket2 = createMockLocalSocket();
183
+ const mockServerSocket = createMockServerSocket();
184
+ vi.mocked(net.connect)
185
+ .mockReturnValueOnce(socket1 as never)
186
+ .mockReturnValueOnce(socket2 as never);
187
+
188
+ const promise1 = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
189
+ socket1._trigger('connect');
190
+ await promise1;
191
+
192
+ const promise2 = portForwardManager.createTunnel('tunnel-2', 4000, mockServerSocket as never);
193
+ socket2._trigger('connect');
194
+ await promise2;
195
+
196
+ portForwardManager.closeAll();
197
+
198
+ expect(socket1.destroy).toHaveBeenCalled();
199
+ expect(socket2.destroy).toHaveBeenCalled();
200
+ });
201
+ });
202
+ });
@@ -0,0 +1,88 @@
1
+ import net from 'node:net';
2
+ import type { Socket } from 'socket.io-client';
3
+
4
+ interface Tunnel {
5
+ id: string;
6
+ port: number;
7
+ localSocket: net.Socket;
8
+ }
9
+
10
+ export class PortForwardManager {
11
+ private tunnels: Map<string, Tunnel> = new Map();
12
+
13
+ async createTunnel(tunnelId: string, port: number, serverSocket: Socket): Promise<void> {
14
+ const localSocket = net.connect({ host: '127.0.0.1', port });
15
+
16
+ return new Promise<void>((resolve) => {
17
+ const onError = (err: Error) => {
18
+ console.error(`Port forward tunnel error: tunnelId=${tunnelId} error=${err.message}`);
19
+ serverSocket.emit('port-forward:created', {
20
+ tunnelId,
21
+ success: false,
22
+ error: err.message,
23
+ });
24
+ localSocket.destroy();
25
+ resolve();
26
+ };
27
+
28
+ localSocket.once('error', onError);
29
+
30
+ localSocket.once('connect', () => {
31
+ localSocket.removeListener('error', onError);
32
+
33
+ this.tunnels.set(tunnelId, { id: tunnelId, port, localSocket });
34
+ console.log(`Port forward tunnel created: tunnelId=${tunnelId} port=${port}`);
35
+
36
+ serverSocket.emit('port-forward:created', { tunnelId, success: true });
37
+
38
+ localSocket.on('data', (data: Buffer) => {
39
+ serverSocket.emit('port-forward:data', {
40
+ tunnelId,
41
+ data: data.toString('base64'),
42
+ });
43
+ });
44
+
45
+ localSocket.on('close', () => {
46
+ console.log(`Port forward local socket closed: tunnelId=${tunnelId}`);
47
+ this.tunnels.delete(tunnelId);
48
+ serverSocket.emit('port-forward:close', { tunnelId });
49
+ });
50
+
51
+ localSocket.on('error', (err: Error) => {
52
+ console.error(`Port forward local socket error: tunnelId=${tunnelId} error=${err.message}`);
53
+ this.tunnels.delete(tunnelId);
54
+ serverSocket.emit('port-forward:error', { tunnelId, error: err.message });
55
+ });
56
+
57
+ resolve();
58
+ });
59
+ });
60
+ }
61
+
62
+ handleTunnelData(tunnelId: string, data: string): void {
63
+ const tunnel = this.tunnels.get(tunnelId);
64
+ if (!tunnel) {
65
+ console.warn(`Port forward data for unknown tunnel: tunnelId=${tunnelId}`);
66
+ return;
67
+ }
68
+ tunnel.localSocket.write(Buffer.from(data, 'base64'));
69
+ }
70
+
71
+ closeTunnel(tunnelId: string): void {
72
+ const tunnel = this.tunnels.get(tunnelId);
73
+ if (!tunnel) return;
74
+ console.log(`Port forward closing tunnel: tunnelId=${tunnelId}`);
75
+ tunnel.localSocket.destroy();
76
+ this.tunnels.delete(tunnelId);
77
+ }
78
+
79
+ closeAll(): void {
80
+ console.log(`Port forward closing all tunnels: count=${this.tunnels.size}`);
81
+ for (const tunnel of this.tunnels.values()) {
82
+ tunnel.localSocket.destroy();
83
+ }
84
+ this.tunnels.clear();
85
+ }
86
+ }
87
+
88
+ export const portForwardManager = new PortForwardManager();
@@ -1,10 +1,11 @@
1
1
  import { io as createSocketIOClient, type Socket } from 'socket.io-client';
2
+ import * as acp from '@agentclientprotocol/sdk';
2
3
  import { hostname, platform } from 'os';
3
4
  import { readdir, access } from 'fs/promises';
4
5
  import { join } from 'path';
5
6
  import { homedir } from 'os';
6
7
  import { Heartbeat } from './heartbeat.js';
7
- import { clearConfigKey, saveKeyToConfig, saveEndpointToConfig } from './config.js';
8
+ import { clearConfigKey, saveKeyToConfig, saveEndpointToConfig, getResumeTokenFromConfig, saveResumeTokenToConfig, clearResumeTokenFromConfig } from './config.js';
8
9
  import { acpDetector } from './acp/detector.js';
9
10
  import { AcpSpawner } from './acp/spawner.js';
10
11
  import { AcpSessionBridge } from './acp/bridge.js';
@@ -12,9 +13,11 @@ import { AcpSyncBridge } from './acp/sync-bridge.js';
12
13
  import { gitHandler } from './git-handler.js';
13
14
  import { worktreeHandler } from './worktree-handler.js';
14
15
  import { fsHandler } from './fs-handler.js';
16
+ import { portForwardManager } from './port-forward.js';
15
17
 
16
- const CONTROLLER_VERSION = '1.0.4';
18
+ const CONTROLLER_VERSION = '1.1.1';
17
19
  const acpBridges = new Map<string, AcpSessionBridge>();
20
+ const permissionRequestBridge = new Map<string, AcpSessionBridge>();
18
21
 
19
22
  function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
20
23
  const config = acpDetector.getSpawnerConfig(serverType);
@@ -23,6 +26,7 @@ function createSpawner(serverType: string, sessionId: string, cwd: string): AcpS
23
26
 
24
27
  interface RegisteredPayload {
25
28
  controllerId: string;
29
+ resumeToken?: string;
26
30
  }
27
31
 
28
32
  interface ErrorPayload {
@@ -154,22 +158,32 @@ export function connectToServer(
154
158
 
155
159
  socket.on('connect', () => {
156
160
  console.log(`Connected to ${endpoint}`);
161
+ const resumeToken = getResumeTokenFromConfig();
157
162
  socket.emit('register', {
158
163
  key,
159
164
  hostname: controllerName,
160
165
  version: CONTROLLER_VERSION,
166
+ resumeToken,
161
167
  });
162
168
  });
163
169
 
164
170
  socket.on('registered', (data: RegisteredPayload) => {
165
171
  console.log(`Controller registered: ${data.controllerId}`);
172
+ if (data.resumeToken) {
173
+ saveResumeTokenToConfig(data.resumeToken);
174
+ console.log('Resume token saved to config');
175
+ }
166
176
  heartbeat.start(() => socket.emit('heartbeat'));
167
177
  });
168
178
 
169
179
  socket.on('error', (data: ErrorPayload) => {
170
180
  console.error(`Server error: ${data.code} - ${data.message}`);
171
181
  if (data.code === 'KEY_ALREADY_ONLINE') {
172
- console.error('This key is already in use on another machine.');
182
+ console.log('Clearing resume token and retrying connection...');
183
+ clearResumeTokenFromConfig();
184
+ // Don't exit, let the reconnection logic handle it
185
+ socket.disconnect();
186
+ return;
173
187
  }
174
188
  socket.disconnect();
175
189
  process.exit(1);
@@ -214,6 +228,10 @@ export function connectToServer(
214
228
 
215
229
  acpBridges.set(data.sessionId, bridge);
216
230
 
231
+ bridge.on('permission:sent', (requestId: string) => {
232
+ permissionRequestBridge.set(requestId, bridge);
233
+ });
234
+
217
235
  const onInitialized = (payload: Record<string, unknown>) => {
218
236
  if (payload.sessionId !== data.sessionId) return;
219
237
  bridge.off('acp:initialized', onInitialized);
@@ -275,6 +293,10 @@ export function connectToServer(
275
293
 
276
294
  acpBridges.set(data.sessionId, bridge);
277
295
 
296
+ bridge.on('permission:sent', (requestId: string) => {
297
+ permissionRequestBridge.set(requestId, bridge);
298
+ });
299
+
278
300
  const onInitialized = (payload: Record<string, unknown>) => {
279
301
  if (payload.sessionId !== data.sessionId) return;
280
302
  bridge.off('acp:initialized', onInitialized);
@@ -563,9 +585,37 @@ export function connectToServer(
563
585
  socket.emit('git:commit:result', { requestId: data.requestId, ...result });
564
586
  });
565
587
 
588
+ // Port Forward events
589
+ socket.on('port-forward:create', async (data: { tunnelId: string; port: number }) => {
590
+ console.log(`Port forward create requested: tunnelId=${data.tunnelId} port=${data.port}`);
591
+ await portForwardManager.createTunnel(data.tunnelId, data.port, socket);
592
+ });
593
+
594
+ socket.on('port-forward:data', (data: { tunnelId: string; data: string }) => {
595
+ portForwardManager.handleTunnelData(data.tunnelId, data.data);
596
+ });
597
+
598
+ socket.on('port-forward:close', (data: { tunnelId: string }) => {
599
+ console.log(`Port forward close: tunnelId=${data.tunnelId}`);
600
+ portForwardManager.closeTunnel(data.tunnelId);
601
+ });
602
+
603
+ socket.on('acp:permission:response', (data: {
604
+ requestId: string;
605
+ outcome: { outcome: string; optionId?: string };
606
+ }) => {
607
+ const bridge = permissionRequestBridge.get(data.requestId);
608
+ if (bridge) {
609
+ permissionRequestBridge.delete(data.requestId);
610
+ bridge.handlePermissionResponse(data.requestId, data.outcome as acp.RequestPermissionOutcome);
611
+ }
612
+ });
613
+
566
614
  socket.on('disconnect', (reason) => {
567
615
  console.log(`Disconnected: ${reason}`);
568
616
  heartbeat.stop();
617
+ portForwardManager.closeAll();
618
+ permissionRequestBridge.clear();
569
619
  });
570
620
 
571
621
  socket.on('connect_error', (err) => {
@@ -331,6 +331,67 @@ describe('AcpSessionBridge', () => {
331
331
 
332
332
  expect(mc().prompt).toHaveBeenCalledTimes(2);
333
333
  });
334
+
335
+ it('should emit acp:prompt:start when prompt begins', async () => {
336
+ const bridge = createBridge();
337
+ bridge.start();
338
+ await vi.advanceTimersByTimeAsync(0);
339
+
340
+ mc().prompt.mockReturnValue(new Promise(() => {}));
341
+ bridge.sendInput({ type: 'text', content: 'Hello' });
342
+
343
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:prompt:start', {
344
+ sessionId: 'sess-1',
345
+ });
346
+ });
347
+
348
+ it('should emit acp:prompt:complete when prompt ends successfully', async () => {
349
+ const bridge = createBridge();
350
+ bridge.start();
351
+ await vi.advanceTimersByTimeAsync(0);
352
+
353
+ bridge.sendInput({ type: 'text', content: 'Hello' });
354
+ await vi.advanceTimersByTimeAsync(0);
355
+
356
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:prompt:complete', {
357
+ sessionId: 'sess-1',
358
+ stopReason: 'end_turn',
359
+ });
360
+ });
361
+
362
+ it('should emit acp:prompt:complete with error stopReason on prompt failure', async () => {
363
+ const bridge = createBridge();
364
+ bridge.start();
365
+ await vi.advanceTimersByTimeAsync(0);
366
+
367
+ mc().prompt.mockRejectedValueOnce(new Error('prompt failed'));
368
+ bridge.sendInput({ type: 'text', content: 'Hello' });
369
+ await vi.advanceTimersByTimeAsync(0);
370
+
371
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:prompt:complete', {
372
+ sessionId: 'sess-1',
373
+ stopReason: 'error',
374
+ });
375
+ });
376
+
377
+ it('should not emit acp:prompt:complete after stop', async () => {
378
+ const bridge = createBridge();
379
+ bridge.start();
380
+ await vi.advanceTimersByTimeAsync(0);
381
+
382
+ mc().prompt.mockReturnValue(new Promise(() => {}));
383
+ bridge.sendInput({ type: 'text', content: 'Hello' });
384
+
385
+ mockSocket.emit.mockClear();
386
+ bridge.stop();
387
+ await vi.advanceTimersByTimeAsync(0);
388
+
389
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', expect.objectContaining({
390
+ sessionId: 'sess-1',
391
+ reason: 'user_close',
392
+ }));
393
+ expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:prompt:complete', expect.anything());
394
+ });
334
395
  });
335
396
 
336
397
  describe('sessionUpdate handling', () => {
@@ -388,18 +449,45 @@ describe('AcpSessionBridge', () => {
388
449
  });
389
450
 
390
451
  describe('requestPermission', () => {
391
- it('should auto-accept with allow_once', async () => {
452
+ it('should emit acp:permission and wait for response', async () => {
392
453
  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
- ],
454
+ const options = [
455
+ { optionId: 'opt-1', kind: 'allow_once', label: 'Allow' },
456
+ { optionId: 'opt-2', kind: 'reject', label: 'Reject' },
457
+ ];
458
+
459
+ const promise = bridge.requestPermission({
460
+ options,
461
+ toolCall: { toolCallId: 'tc-1' },
398
462
  } as never);
399
463
 
400
- expect(result).toEqual({
401
- outcome: { outcome: 'selected', optionId: 'opt-1' },
402
- });
464
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:permission', expect.objectContaining({
465
+ sessionId: 'sess-1',
466
+ toolCall: { toolCallId: 'tc-1' },
467
+ options,
468
+ }));
469
+
470
+ const emitCall = mockSocket.emit.mock.calls.find((c: unknown[]) => c[0] === 'acp:permission');
471
+ const requestId = (emitCall![1] as { requestId: string }).requestId;
472
+
473
+ bridge.handlePermissionResponse(requestId, { outcome: 'selected', optionId: 'opt-1' });
474
+
475
+ const result = await promise;
476
+ expect(result).toEqual({ outcome: { outcome: 'selected', optionId: 'opt-1' } });
477
+ });
478
+
479
+ it('should resolve with cancelled on 65s timeout', async () => {
480
+ const bridge = createBridge();
481
+
482
+ const promise = bridge.requestPermission({
483
+ options: [{ optionId: 'opt-1', kind: 'allow_once', label: 'Allow' }],
484
+ toolCall: { toolCallId: 'tc-1' },
485
+ } as never);
486
+
487
+ await vi.advanceTimersByTimeAsync(65_000);
488
+
489
+ const result = await promise;
490
+ expect(result).toEqual({ outcome: { outcome: 'cancelled' } });
403
491
  });
404
492
  });
405
493