@agentrq/acp-gateway 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -42,7 +42,7 @@ npm install -g @agentrq/acp-gateway
42
42
 
43
43
  ## Current Version
44
44
 
45
- `0.2.3`
45
+ `0.2.5`
46
46
 
47
47
  ## Usage
48
48
 
@@ -66,12 +66,99 @@ acp-gateway -- your-acp-agent --flag1 --flag2
66
66
  You can specify gateway options before the `--` separator:
67
67
 
68
68
  - `--max-concurrency` / `--maxConcurrency` `<number>`: Sets the maximum number of concurrent tasks allowed to prompt the ACP agent at once. Defaults to `2`.
69
+ - `--agent <registry-id>`: Runs an agent from the ACP registry instead of a command you supply yourself.
70
+ - `--list-agents`: Prints every agent in the registry and how each one can run on this machine, then exits.
71
+ - `--allow-unverified-agent`: Installs a registry binary that publishes no checksum. Off by default.
72
+ - `--registry-url <url>`: Reads a different registry index (for pinning, or for testing).
73
+ - `--auth-method <id>`: The authentication method to use when the agent asks for a login. Defaults to picking one automatically.
74
+ - `--help` / `-h`: Explains every option, with examples. Also shown when `acp-gateway` is run with nothing to do.
75
+ - `--list-auth-methods`: Prints the login methods the agent advertises, then exits.
76
+ - `--login [method-id]`: Logs in to the agent, then exits.
77
+ - `--logout`: Ends the agent's authenticated state, then exits (only for agents that support logout).
69
78
 
70
79
  Example:
71
80
  ```bash
72
81
  acp-gateway --max-concurrency 4 -- gemini --acp
73
82
  ```
74
83
 
84
+ ### Running an Agent from the Registry
85
+
86
+ The [ACP registry](https://github.com/agentclientprotocol/registry) lists agents that implement the
87
+ protocol, and `acp-gateway` can run one by name — no installing or wiring up by hand:
88
+
89
+ ```bash
90
+ acp-gateway --list-agents # see what's published
91
+ acp-gateway --agent gemini # run one
92
+ ```
93
+
94
+ Registry entries are distributed in three ways, and the gateway prefers them in this order:
95
+
96
+ | Distribution | How it runs | Downloads anything? |
97
+ |---|---|---|
98
+ | `npx` | `npx -y <package> <args>` | No — npm fetches and verifies the package |
99
+ | `uvx` | `uvx <package> <args>` | No — uv fetches and verifies the package |
100
+ | `binary` | Archive is downloaded, checked, unpacked and cached | Yes |
101
+
102
+ Package distributions come first because npm and PyPI verify what they serve. A binary is only used
103
+ when it is the agent's only distribution.
104
+
105
+ **Binaries are verified before they run.** The registry's `sha256` for the archive is optional, and
106
+ roughly half of the published binary targets omit it. Where there is no checksum the gateway refuses
107
+ to install rather than run something it cannot vouch for:
108
+
109
+ ```
110
+ The ACP registry publishes no sha256 for "antigravity-acp" on this platform, so the download
111
+ cannot be verified. Re-run with --allow-unverified-agent to install it anyway, or install the
112
+ agent yourself and pass it after --.
113
+ ```
114
+
115
+ Downloads are cached per agent, version and platform, so each build is fetched once:
116
+
117
+ | Platform | Cache location |
118
+ |---|---|
119
+ | macOS / Linux | `$XDG_CACHE_HOME/acp-gateway/agents`, else `~/.cache/acp-gateway/agents` |
120
+ | Windows | `%LOCALAPPDATA%\acp-gateway\agents` |
121
+
122
+ All six platforms the registry publishes for are supported — macOS, Linux and Windows on both x86_64
123
+ and arm64. Archives are unpacked with the system's own `tar` (and `unzip` for zips on Linux, whose
124
+ `tar` cannot read them), so no archive libraries are added as dependencies.
125
+
126
+ ### Authentication
127
+
128
+ Agents that require a login advertise their login methods during the ACP handshake, and refuse to open a
129
+ session until one has been used. `acp-gateway` handles that the same way an editor such as Zed does.
130
+
131
+ List what an agent offers:
132
+
133
+ ```bash
134
+ acp-gateway --list-auth-methods -- gemini --acp
135
+ ```
136
+
137
+ Log in ahead of time — with no method id, you are asked to pick one:
138
+
139
+ ```bash
140
+ acp-gateway --login -- gemini --acp
141
+ acp-gateway --login oauth-personal -- gemini --acp # or name the method directly
142
+ ```
143
+
144
+ Log out again:
145
+
146
+ ```bash
147
+ acp-gateway --logout -- gemini --acp
148
+ ```
149
+
150
+ Nothing has to be done up front, though: if an agent refuses the first session because it needs a login,
151
+ `acp-gateway` logs in and retries by itself.
152
+
153
+ There are two kinds of method:
154
+
155
+ - **Agent** methods — the agent runs the login itself (a browser flow, an API key it already holds). These
156
+ need nobody present, so they are chosen first and work in an unattended gateway.
157
+ - **Terminal** methods — the agent's own binary is re-run interactively so you can log in at a TUI.
158
+ `acp-gateway` only offers to run these when it has a real terminal to hand over.
159
+
160
+ Credentials are never stored by the gateway; the agent keeps its own, exactly as it does under an editor.
161
+
75
162
  ### Configuration
76
163
 
77
164
  `acp-gateway` searches for `.mcp.json` starting in the current working directory and up to 3 parent directories.
@@ -134,6 +221,9 @@ Example `.mcp.json`:
134
221
  | `src/acpClient.ts` | Implements the ACP `Client` interface — routes permission requests, handles session updates, and provides file operations. |
135
222
  | `src/mcpClient.ts` | `EventEmitter`-based MCP client with auto-reconnection, notification handling, and tool call dispatch. |
136
223
  | `src/config.ts` | Parses `.mcp.json` from the current directory tree up to 3 levels deep. |
224
+ | `src/auth.ts` | ACP authentication — lists the agent's login methods, detects `auth_required`, and runs agent or terminal logins. |
225
+ | `src/registry.ts` | Reads the ACP registry index — agent lookup, host platform matching, and package launch commands. |
226
+ | `src/agentInstall.ts` | Downloads, verifies, unpacks and caches a registry agent's binary distribution. |
137
227
 
138
228
  ## Development
139
229
 
@@ -156,9 +246,12 @@ npm test
156
246
  acp-gateway/
157
247
  ├── src/
158
248
  │ ├── acpClient.ts # ACP Client implementation
249
+ │ ├── agentInstall.ts # Registry binary download / verify / cache
250
+ │ ├── auth.ts # ACP authentication (login / logout)
159
251
  │ ├── config.ts # .mcp.json loader
160
252
  │ ├── index.ts # Entry point & orchestrator
161
253
  │ ├── mcpClient.ts # MCP Bridge with auto-reconnect
254
+ │ ├── registry.ts # ACP registry index client
162
255
  │ └── __tests__/ # Unit tests
163
256
  ├── package.json
164
257
  └── tsconfig.json
@@ -169,6 +262,8 @@ acp-gateway/
169
262
  - **Auto-reconnection**: The MCP transport auto-reconnects on disconnection with exponential backoff (1s → 30s max).
170
263
  - **Notification-driven tasks**: The MCP server pushes task content via `notifications/claude/channel`; `acp-gateway` reacts immediately.
171
264
  - **Permission flow**: ACP agent requests permission → `acp-gateway` forwards to MCP server → waits for verdict → resolves the ACP permission.
265
+ - **Registry agents**: `--agent <id>` resolves through the registry index; package distributions are preferred over binaries, and a binary without a published `sha256` is refused unless explicitly allowed.
266
+ - **Authentication**: Login methods come from the `initialize` handshake; an `auth_required` refusal triggers a login and one retry of `newSession`.
172
267
  - **File I/O**: `readTextFile` / `writeTextFile` are proxied directly to the filesystem; paths are resolved relative to `process.cwd()`.
173
268
 
174
269
  ## Contributing
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
2
3
  import { AgentRQACPClient } from "../acpClient.js";
3
4
  import * as fs from "node:fs/promises";
4
5
  import * as path from "node:path";
@@ -8,15 +9,25 @@ vi.mock("node:path");
8
9
  describe("AgentRQACPClient", () => {
9
10
  let mcpBridge;
10
11
  let client;
12
+ /**
13
+ * Answers the tool call that is waiting, the way agentrq does: by echoing
14
+ * back the request id the gateway actually sent.
15
+ */
16
+ function answerWith(behavior) {
17
+ setTimeout(() => {
18
+ const sent = mcpBridge.sendNotification.mock.calls.at(-1)?.[1];
19
+ mcpBridge.emit("verdict", { requestId: sent?.request_id, behavior });
20
+ }, 10);
21
+ }
11
22
  beforeEach(() => {
12
23
  vi.clearAllMocks();
13
- mcpBridge = {
24
+ // A real emitter: the client registers one shared verdict listener when it
25
+ // is constructed, so a mocked `on` would never see a verdict at all.
26
+ mcpBridge = Object.assign(new EventEmitter(), {
14
27
  getSessionId: vi.fn().mockReturnValue("test-session"),
15
28
  sendNotification: vi.fn().mockResolvedValue(undefined),
16
29
  callTool: vi.fn(),
17
- on: vi.fn(),
18
- off: vi.fn(),
19
- };
30
+ });
20
31
  client = new AgentRQACPClient(mcpBridge);
21
32
  });
22
33
  describe("requestPermission", () => {
@@ -32,11 +43,7 @@ describe("AgentRQACPClient", () => {
32
43
  { optionId: "opt-2", kind: "deny", name: "Deny" },
33
44
  ],
34
45
  };
35
- mcpBridge.on.mockImplementation((event, handler) => {
36
- if (event === "verdict") {
37
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
38
- }
39
- });
46
+ answerWith("allow");
40
47
  const response = await client.requestPermission(params);
41
48
  expect(response.outcome.optionId).toBe("opt-1");
42
49
  });
@@ -58,8 +65,8 @@ describe("AgentRQACPClient", () => {
58
65
  // here would surface as an unhandled rejection and crash the gateway.
59
66
  const response = await client.requestPermission(params);
60
67
  expect(response.outcome.outcome).toBe("cancelled");
61
- // It must not register a verdict listener it can never clean up.
62
- expect(mcpBridge.on).not.toHaveBeenCalled();
68
+ // Nothing may be left waiting on a verdict that will never come.
69
+ expect(client.pendingPermissionCount).toBe(0);
63
70
  consoleSpy.mockRestore();
64
71
  });
65
72
  it("should include task_id in the payload when available", async () => {
@@ -77,11 +84,7 @@ describe("AgentRQACPClient", () => {
77
84
  { optionId: "opt-2", kind: "deny", name: "Deny" },
78
85
  ],
79
86
  };
80
- mcpBridge.on.mockImplementation((event, handler) => {
81
- if (event === "verdict") {
82
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
83
- }
84
- });
87
+ answerWith("allow");
85
88
  await clientWithTaskId.requestPermission(params);
86
89
  expect(getTaskId).toHaveBeenCalledWith("sess-1");
87
90
  expect(mcpBridge.sendNotification).toHaveBeenCalledWith("notifications/claude/channel/permission_request", expect.objectContaining({
@@ -112,11 +115,7 @@ describe("AgentRQACPClient", () => {
112
115
  toolCall: { toolCallId: "req-123" },
113
116
  options: [{ optionId: "opt-1", kind: "allow", name: "Allow" }],
114
117
  };
115
- mcpBridge.on.mockImplementation((event, handler) => {
116
- if (event === "verdict") {
117
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
118
- }
119
- });
118
+ answerWith("allow");
120
119
  const response = await client.requestPermission(params);
121
120
  // Permission matching still works based on behavior, independent of title presence
122
121
  expect(response.outcome.optionId).toBe("opt-1");
@@ -130,11 +129,7 @@ describe("AgentRQACPClient", () => {
130
129
  toolCall: { toolCallId: "req-123", title: "Test Tool" },
131
130
  options: [{ optionId: "opt-1", kind: "allow", name: "Allow" }],
132
131
  };
133
- mcpBridge.on.mockImplementation((event, handler) => {
134
- if (event === "verdict") {
135
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
136
- }
137
- });
132
+ answerWith("allow");
138
133
  const response = await client.requestPermission(params);
139
134
  expect(response.outcome.optionId).toBe("opt-1");
140
135
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Bridge Session ID: unknown"));
@@ -188,11 +183,7 @@ describe("AgentRQACPClient", () => {
188
183
  { optionId: "opt-2", kind: "reject_once", name: "Reject" },
189
184
  ],
190
185
  };
191
- mcpBridge.on.mockImplementation((event, handler) => {
192
- if (event === "verdict") {
193
- setTimeout(() => handler({ requestId: "call_abc", behavior: "allow" }), 10);
194
- }
195
- });
186
+ answerWith("allow");
196
187
  await client.requestPermission(params);
197
188
  // A non-agentrq tool still goes to the human, but now with a meaningful
198
189
  // name and input rather than "Unknown Tool" / "{}".
@@ -224,11 +215,7 @@ describe("AgentRQACPClient", () => {
224
215
  { optionId: "opt-2", kind: "reject_once", name: "Reject" },
225
216
  ],
226
217
  };
227
- mcpBridge.on.mockImplementation((event, handler) => {
228
- if (event === "verdict") {
229
- setTimeout(() => handler({ requestId: "call_cmd", behavior: "allow" }), 10);
230
- }
231
- });
218
+ answerWith("allow");
232
219
  await client.requestPermission(params);
233
220
  expect(mcpBridge.sendNotification).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
234
221
  tool_name: "Run command",
@@ -244,11 +231,7 @@ describe("AgentRQACPClient", () => {
244
231
  { optionId: "opt-2", kind: "reject_once", name: "Reject" },
245
232
  ],
246
233
  };
247
- mcpBridge.on.mockImplementation((event, handler) => {
248
- if (event === "verdict") {
249
- setTimeout(() => handler({ requestId: "call_unseen", behavior: "allow" }), 10);
250
- }
251
- });
234
+ answerWith("allow");
252
235
  await client.requestPermission(params);
253
236
  expect(mcpBridge.sendNotification).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ tool_name: "Unknown Tool", input_preview: "{}" }));
254
237
  });
@@ -270,11 +253,7 @@ describe("AgentRQACPClient", () => {
270
253
  { optionId: "opt-2", kind: "reject_once", name: "Reject" },
271
254
  ],
272
255
  };
273
- mcpBridge.on.mockImplementation((event, handler) => {
274
- if (event === "verdict") {
275
- setTimeout(() => handler({ requestId: "call_done", behavior: "allow" }), 10);
276
- }
277
- });
256
+ answerWith("allow");
278
257
  await client.requestPermission(params);
279
258
  // Entry was dropped on completion, so it is no longer auto-allowed by
280
259
  // the remembered title — it goes to the human as an unknown tool.
@@ -303,11 +282,7 @@ describe("AgentRQACPClient", () => {
303
282
  expect(first.outcome.optionId).toBe("opt-1");
304
283
  expect(mcpBridge.sendNotification).not.toHaveBeenCalled();
305
284
  // A replay of the same id no longer resolves, so it reaches the human.
306
- mcpBridge.on.mockImplementation((event, handler) => {
307
- if (event === "verdict") {
308
- setTimeout(() => handler({ requestId: "call_once", behavior: "allow" }), 10);
309
- }
310
- });
285
+ answerWith("allow");
311
286
  await client.requestPermission(params);
312
287
  expect(mcpBridge.sendNotification).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ tool_name: "Unknown Tool" }));
313
288
  });
@@ -360,11 +335,7 @@ describe("AgentRQACPClient", () => {
360
335
  { optionId: "opt-2", kind: "reject_once", name: "Reject" },
361
336
  ],
362
337
  };
363
- mcpBridge.on.mockImplementation((event, handler) => {
364
- if (event === "verdict") {
365
- setTimeout(() => handler({ requestId: "call_bare", behavior: "allow" }), 10);
366
- }
367
- });
338
+ answerWith("allow");
368
339
  await client.requestPermission(params);
369
340
  expect(mcpBridge.sendNotification).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ tool_name: "Unknown Tool", input_preview: "{}" }));
370
341
  });
@@ -382,11 +353,7 @@ describe("AgentRQACPClient", () => {
382
353
  { optionId: "opt-2", kind: "other", name: "No" },
383
354
  ],
384
355
  };
385
- mcpBridge.on.mockImplementation((event, handler) => {
386
- if (event === "verdict") {
387
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
388
- }
389
- });
356
+ answerWith("allow");
390
357
  const response = await client.requestPermission(params);
391
358
  expect(response.outcome.optionId).toBe("opt-1");
392
359
  });
@@ -398,11 +365,7 @@ describe("AgentRQACPClient", () => {
398
365
  { optionId: "opt-2", kind: "other", name: "Deny this" },
399
366
  ],
400
367
  };
401
- mcpBridge.on.mockImplementation((event, handler) => {
402
- if (event === "verdict") {
403
- setTimeout(() => handler({ requestId: "req-123", behavior: "deny" }), 10);
404
- }
405
- });
368
+ answerWith("deny");
406
369
  const response = await client.requestPermission(params);
407
370
  expect(response.outcome.optionId).toBe("opt-2");
408
371
  });
@@ -414,11 +377,7 @@ describe("AgentRQACPClient", () => {
414
377
  { optionId: "opt-2", kind: "other", name: "Other 2" },
415
378
  ],
416
379
  };
417
- mcpBridge.on.mockImplementation((event, handler) => {
418
- if (event === "verdict") {
419
- setTimeout(() => handler({ requestId: "req-123", behavior: "deny" }), 10);
420
- }
421
- });
380
+ answerWith("deny");
422
381
  const response = await client.requestPermission(params);
423
382
  expect(response.outcome.optionId).toBe("opt-1");
424
383
  });
@@ -430,11 +389,7 @@ describe("AgentRQACPClient", () => {
430
389
  { optionId: "opt-2", kind: "reject_once", name: "Reject Once" },
431
390
  ],
432
391
  };
433
- mcpBridge.on.mockImplementation((event, handler) => {
434
- if (event === "verdict") {
435
- setTimeout(() => handler({ requestId: "req-123", behavior: "deny" }), 10);
436
- }
437
- });
392
+ answerWith("deny");
438
393
  const response = await client.requestPermission(params);
439
394
  // A spec-compliant "reject_once" kind must never be missed and fall
440
395
  // through to the first (allow) option — that would silently approve a
@@ -449,11 +404,7 @@ describe("AgentRQACPClient", () => {
449
404
  { optionId: "opt-2", kind: "other", name: "Skip" },
450
405
  ],
451
406
  };
452
- mcpBridge.on.mockImplementation((event, handler) => {
453
- if (event === "verdict") {
454
- setTimeout(() => handler({ requestId: "req-123", behavior: "deny" }), 10);
455
- }
456
- });
407
+ answerWith("deny");
457
408
  const response = await client.requestPermission(params);
458
409
  expect(response.outcome.optionId).toBe("opt-2");
459
410
  });
@@ -462,11 +413,7 @@ describe("AgentRQACPClient", () => {
462
413
  toolCall: { toolCallId: "req-123" },
463
414
  options: [{ optionId: "opt-1", kind: "allow_once", name: "Proceed" }],
464
415
  };
465
- mcpBridge.on.mockImplementation((event, handler) => {
466
- if (event === "verdict") {
467
- setTimeout(() => handler({ requestId: "req-123", behavior: "deny" }), 10);
468
- }
469
- });
416
+ answerWith("deny");
470
417
  const response = await client.requestPermission(params);
471
418
  expect(response.outcome.outcome).toBe("cancelled");
472
419
  });
@@ -478,11 +425,7 @@ describe("AgentRQACPClient", () => {
478
425
  { optionId: "opt-once", kind: "allow_once", name: "Allow Once" },
479
426
  ],
480
427
  };
481
- mcpBridge.on.mockImplementation((event, handler) => {
482
- if (event === "verdict") {
483
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
484
- }
485
- });
428
+ answerWith("allow");
486
429
  const response = await client.requestPermission(params);
487
430
  // Selecting "allow_always" would make the spawned agent remember this
488
431
  // decision and stop asking for matching future tool calls, bypassing
@@ -497,11 +440,7 @@ describe("AgentRQACPClient", () => {
497
440
  { optionId: "opt-once", kind: "reject_once", name: "Reject Once" },
498
441
  ],
499
442
  };
500
- mcpBridge.on.mockImplementation((event, handler) => {
501
- if (event === "verdict") {
502
- setTimeout(() => handler({ requestId: "req-123", behavior: "deny" }), 10);
503
- }
504
- });
443
+ answerWith("deny");
505
444
  const response = await client.requestPermission(params);
506
445
  expect(response.outcome.optionId).toBe("opt-once");
507
446
  });
@@ -510,11 +449,7 @@ describe("AgentRQACPClient", () => {
510
449
  toolCall: { toolCallId: "req-123" },
511
450
  options: [{ optionId: "opt-always", kind: "allow_always", name: "Always Allow" }],
512
451
  };
513
- mcpBridge.on.mockImplementation((event, handler) => {
514
- if (event === "verdict") {
515
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
516
- }
517
- });
452
+ answerWith("allow");
518
453
  const response = await client.requestPermission(params);
519
454
  expect(response.outcome.outcome).toBe("cancelled");
520
455
  });
@@ -523,11 +458,7 @@ describe("AgentRQACPClient", () => {
523
458
  toolCall: { toolCallId: "req-123" },
524
459
  options: [{ optionId: "opt-default", kind: "other", name: "Maybe" }],
525
460
  };
526
- mcpBridge.on.mockImplementation((event, handler) => {
527
- if (event === "verdict") {
528
- setTimeout(() => handler({ requestId: "req-123", behavior: "allow" }), 10);
529
- }
530
- });
461
+ answerWith("allow");
531
462
  const response = await client.requestPermission(params);
532
463
  expect(response.outcome.optionId).toBe("opt-default");
533
464
  });
@@ -865,6 +796,189 @@ describe("AgentRQACPClient", () => {
865
796
  expect(mcpBridge.callTool).toHaveBeenCalledWith("reply", { chatId: "task-123", text: "Hello world" });
866
797
  });
867
798
  });
799
+ describe("waiting for a verdict", () => {
800
+ const params = (overrides = {}) => ({
801
+ sessionId: "sess-1",
802
+ toolCall: { toolCallId: "call-1", title: "Bash", rawInput: { command: "ls" } },
803
+ options: [
804
+ { optionId: "opt-1", kind: "allow_once", name: "Allow" },
805
+ { optionId: "opt-2", kind: "reject_once", name: "Deny" },
806
+ ],
807
+ ...overrides,
808
+ });
809
+ it("should scope the request id to the session it came from", async () => {
810
+ answerWith("allow");
811
+ await client.requestPermission(params());
812
+ // agentrq keys its bookkeeping on the request id alone, workspace-wide,
813
+ // while tool call ids are only unique within one session.
814
+ expect(mcpBridge.sendNotification.mock.calls[0][1].request_id).toBe("sess-1:call-1");
815
+ });
816
+ it("should fall back to the bare tool call id when there is no session", async () => {
817
+ answerWith("allow");
818
+ await client.requestPermission(params({ sessionId: undefined }));
819
+ expect(mcpBridge.sendNotification.mock.calls[0][1].request_id).toBe("call-1");
820
+ });
821
+ it("should keep one verdict listener however many calls are waiting", async () => {
822
+ const waiting = [
823
+ client.requestPermission(params()),
824
+ client.requestPermission(params({ toolCall: { toolCallId: "call-2", title: "Bash" } })),
825
+ client.requestPermission(params({ toolCall: { toolCallId: "call-3", title: "Bash" } })),
826
+ ];
827
+ await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(3));
828
+ // A listener per request was only ever removed on a matching verdict, so
829
+ // every unanswered request leaked one for the life of the process.
830
+ expect(mcpBridge.listenerCount("verdict")).toBe(1);
831
+ client.cancelPendingPermissions("test");
832
+ await Promise.all(waiting);
833
+ expect(client.pendingPermissionCount).toBe(0);
834
+ });
835
+ it("should ignore a verdict for a call that is not waiting", async () => {
836
+ answerWith("allow");
837
+ await client.requestPermission(params());
838
+ expect(() => mcpBridge.emit("verdict", { requestId: "gone", behavior: "allow" })).not.toThrow();
839
+ });
840
+ it("should give up on a call nobody answers, and stop the turn", async () => {
841
+ vi.useFakeTimers();
842
+ const cancel = vi.fn();
843
+ const bounded = new AgentRQACPClient(mcpBridge, () => "task-1", {
844
+ permissionTimeoutMs: 60_000,
845
+ });
846
+ bounded.setSessionCanceller(cancel);
847
+ const waiting = bounded.requestPermission(params());
848
+ await vi.waitFor(() => expect(bounded.pendingPermissionCount).toBe(1));
849
+ await vi.advanceTimersByTimeAsync(60_000);
850
+ // Cancelling as well as answering: the spec treats "cancelled" as the
851
+ // answer a client gives because it cancelled the turn. Answering alone
852
+ // would leave the agent free to carry on and ask again.
853
+ expect((await waiting).outcome.outcome).toBe("cancelled");
854
+ expect(cancel).toHaveBeenCalledWith("sess-1");
855
+ expect(bounded.pendingPermissionCount).toBe(0);
856
+ vi.useRealTimers();
857
+ });
858
+ it("should survive a turn that cannot be cancelled", async () => {
859
+ vi.useFakeTimers();
860
+ const bounded = new AgentRQACPClient(mcpBridge, () => "task-1", {
861
+ permissionTimeoutMs: 60_000,
862
+ });
863
+ bounded.setSessionCanceller(() => {
864
+ throw new Error("connection gone");
865
+ });
866
+ const waiting = bounded.requestPermission(params());
867
+ await vi.waitFor(() => expect(bounded.pendingPermissionCount).toBe(1));
868
+ await vi.advanceTimersByTimeAsync(60_000);
869
+ expect((await waiting).outcome.outcome).toBe("cancelled");
870
+ vi.useRealTimers();
871
+ });
872
+ it("should wait indefinitely when the timeout is switched off", async () => {
873
+ vi.useFakeTimers();
874
+ const unbounded = new AgentRQACPClient(mcpBridge, () => "task-1", {
875
+ permissionTimeoutMs: 0,
876
+ });
877
+ const waiting = unbounded.requestPermission(params());
878
+ await vi.waitFor(() => expect(unbounded.pendingPermissionCount).toBe(1));
879
+ await vi.advanceTimersByTimeAsync(24 * 60 * 60_000);
880
+ expect(unbounded.pendingPermissionCount).toBe(1);
881
+ unbounded.cancelPendingPermissions("test over");
882
+ expect((await waiting).outcome.outcome).toBe("cancelled");
883
+ vi.useRealTimers();
884
+ });
885
+ it("should answer everything still waiting when the agent is gone", async () => {
886
+ const waiting = client.requestPermission(params());
887
+ await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(1));
888
+ client.cancelPendingPermissions("agent process exited");
889
+ expect((await waiting).outcome.outcome).toBe("cancelled");
890
+ expect(client.pendingPermissionCount).toBe(0);
891
+ });
892
+ it("should re-send waiting calls when the workspace reconnects", async () => {
893
+ const waiting = client.requestPermission(params());
894
+ await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(1));
895
+ const sentFirst = mcpBridge.sendNotification.mock.calls.length;
896
+ mcpBridge.emit("reconnected");
897
+ await vi.waitFor(() => expect(mcpBridge.sendNotification.mock.calls.length).toBe(sentFirst + 1));
898
+ // Same request id: the workspace has to recognise this as the decision it
899
+ // is already showing, not a new one to ask about again.
900
+ const [first, resent] = mcpBridge.sendNotification.mock.calls.map((c) => c[1]);
901
+ expect(resent).toEqual(first);
902
+ expect(client.pendingPermissionCount).toBe(1);
903
+ answerWith("allow");
904
+ expect((await waiting).outcome.outcome).toBe("selected");
905
+ });
906
+ it("should not talk to the workspace on reconnect when nothing is waiting", () => {
907
+ mcpBridge.emit("reconnected");
908
+ expect(mcpBridge.sendNotification).not.toHaveBeenCalled();
909
+ });
910
+ it("should keep waiting when the re-send itself fails", async () => {
911
+ const waiting = client.requestPermission(params());
912
+ await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(1));
913
+ mcpBridge.sendNotification.mockRejectedValueOnce(new Error("still down"));
914
+ mcpBridge.emit("reconnected");
915
+ await vi.waitFor(() => expect(mcpBridge.sendNotification.mock.calls.length).toBeGreaterThan(1));
916
+ // The next reconnect gets another go; giving up here would lose the turn.
917
+ expect(client.pendingPermissionCount).toBe(1);
918
+ client.cancelPendingPermissions("test over");
919
+ await waiting;
920
+ });
921
+ it("should say nothing when there is nothing waiting to cancel", () => {
922
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
923
+ client.cancelPendingPermissions("nothing doing");
924
+ expect(errorSpy).not.toHaveBeenCalled();
925
+ errorSpy.mockRestore();
926
+ });
927
+ });
928
+ describe("session mode changes", () => {
929
+ it("should hand a mode change to whoever is watching for it", async () => {
930
+ const onMode = vi.fn();
931
+ client.setModeChangeHandler(onMode);
932
+ await client.sessionUpdate({
933
+ sessionId: "sess-1",
934
+ update: { sessionUpdate: "current_mode_update", currentModeId: "auto" },
935
+ });
936
+ expect(onMode).toHaveBeenCalledWith("sess-1", "auto");
937
+ });
938
+ it("should not mind a mode change nobody is watching for", async () => {
939
+ await expect(client.sessionUpdate({
940
+ sessionId: "sess-1",
941
+ update: { sessionUpdate: "current_mode_update", currentModeId: "auto" },
942
+ })).resolves.toBeUndefined();
943
+ });
944
+ });
945
+ describe("reportStopReason", () => {
946
+ function clientForTask(taskId) {
947
+ return new AgentRQACPClient(mcpBridge, () => taskId);
948
+ }
949
+ it("should say nothing when the agent simply finished", async () => {
950
+ await clientForTask("task-1").reportStopReason("sess-1", "end_turn");
951
+ expect(mcpBridge.callTool).not.toHaveBeenCalled();
952
+ });
953
+ it("should tell the workspace when a turn was refused or cut short", async () => {
954
+ const client = clientForTask("task-1");
955
+ await client.reportStopReason("sess-1", "refusal");
956
+ await client.reportStopReason("sess-1", "max_tokens");
957
+ await client.reportStopReason("sess-1", "max_turn_requests");
958
+ await client.reportStopReason("sess-1", "cancelled");
959
+ const texts = mcpBridge.callTool.mock.calls.map((c) => c[1].text);
960
+ expect(mcpBridge.callTool.mock.calls.every((c) => c[0] === "reply")).toBe(true);
961
+ expect(texts[0]).toContain("refused");
962
+ expect(texts[1]).toContain("ran out of output tokens");
963
+ expect(texts[2]).toContain("model requests");
964
+ expect(texts[3]).toContain("cancelled");
965
+ });
966
+ it("should still report a stop reason it does not recognise", async () => {
967
+ await clientForTask("task-1").reportStopReason("sess-1", "something_new");
968
+ expect(mcpBridge.callTool).toHaveBeenCalledWith("reply", {
969
+ chatId: "task-1",
970
+ text: expect.stringContaining("something_new"),
971
+ });
972
+ });
973
+ it("should skip a session with no task behind it", async () => {
974
+ await clientForTask(undefined).reportStopReason("sess-1", "refusal");
975
+ expect(mcpBridge.callTool).not.toHaveBeenCalled();
976
+ });
977
+ it("should survive a workspace that cannot be reached", async () => {
978
+ mcpBridge.callTool.mockRejectedValue(new Error("offline"));
979
+ await expect(clientForTask("task-1").reportStopReason("sess-1", "refusal")).resolves.toBeUndefined();
980
+ });
981
+ });
868
982
  describe("file operations", () => {
869
983
  it("should read text files", async () => {
870
984
  vi.mocked(path.resolve).mockReturnValue("/mock/path/file.txt");