@agentrq/acp-gateway 0.2.4 → 0.2.6
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 +3 -1
- package/dist/__tests__/acpClient.test.js +635 -106
- package/dist/__tests__/acpClient.test.js.map +1 -1
- package/dist/__tests__/agentInfo.test.js +79 -0
- package/dist/__tests__/agentInfo.test.js.map +1 -0
- package/dist/__tests__/config.test.js +45 -0
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/index.test.js +283 -15
- package/dist/__tests__/index.test.js.map +1 -1
- package/dist/__tests__/mcpClient.test.js +94 -1
- package/dist/__tests__/mcpClient.test.js.map +1 -1
- package/dist/__tests__/telemetry.test.js +151 -0
- package/dist/__tests__/telemetry.test.js.map +1 -0
- package/dist/acpClient.js +380 -59
- package/dist/acpClient.js.map +1 -1
- package/dist/agentInfo.js +69 -0
- package/dist/agentInfo.js.map +1 -0
- package/dist/config.js +50 -16
- package/dist/config.js.map +1 -1
- package/dist/index.js +259 -19
- package/dist/index.js.map +1 -1
- package/dist/mcpClient.js +37 -1
- package/dist/mcpClient.js.map +1 -1
- package/dist/telemetry.js +106 -0
- package/dist/telemetry.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
62
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,264 @@ 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 cancel only the pending permissions for the specified session", async () => {
|
|
922
|
+
const waitSess1 = client.requestPermission(params({ sessionId: "sess-1", toolCall: { toolCallId: "call-1", title: "Bash" } }));
|
|
923
|
+
const waitSess2 = client.requestPermission(params({ sessionId: "sess-2", toolCall: { toolCallId: "call-2", title: "Bash" } }));
|
|
924
|
+
const waitNoSess = client.requestPermission(params({ sessionId: undefined, toolCall: { toolCallId: "call-3", title: "Bash" } }));
|
|
925
|
+
await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(3));
|
|
926
|
+
client.cancelPendingPermissions("task cancelled", "sess-1");
|
|
927
|
+
expect((await waitSess1).outcome.outcome).toBe("cancelled");
|
|
928
|
+
expect(client.pendingPermissionCount).toBe(2);
|
|
929
|
+
// Requests with undefined sessionId should NOT be cancelled by a session-scoped cancel
|
|
930
|
+
mcpBridge.emit("verdict", { requestId: "sess-2:call-2", behavior: "allow" });
|
|
931
|
+
expect((await waitSess2).outcome.outcome).toBe("selected");
|
|
932
|
+
mcpBridge.emit("verdict", { requestId: "call-3", behavior: "allow" });
|
|
933
|
+
expect((await waitNoSess).outcome.outcome).toBe("selected");
|
|
934
|
+
expect(client.pendingPermissionCount).toBe(0);
|
|
935
|
+
});
|
|
936
|
+
it("should do nothing if sessionId does not match any waiting permissions", async () => {
|
|
937
|
+
const waiting = client.requestPermission(params({ sessionId: "sess-1" }));
|
|
938
|
+
await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(1));
|
|
939
|
+
client.cancelPendingPermissions("task cancelled", "sess-999");
|
|
940
|
+
expect(client.pendingPermissionCount).toBe(1);
|
|
941
|
+
answerWith("allow");
|
|
942
|
+
expect((await waiting).outcome.outcome).toBe("selected");
|
|
943
|
+
});
|
|
944
|
+
it("should send session/cancel RPC before settling pending permissions", async () => {
|
|
945
|
+
const callOrder = [];
|
|
946
|
+
const cancelSession = vi.fn().mockImplementation(async () => {
|
|
947
|
+
callOrder.push("cancelRPC");
|
|
948
|
+
});
|
|
949
|
+
client.setSessionCanceller(cancelSession);
|
|
950
|
+
const waiting = client.requestPermission(params({ sessionId: "sess-1" })).then((res) => {
|
|
951
|
+
callOrder.push("settledPermission");
|
|
952
|
+
return res;
|
|
953
|
+
});
|
|
954
|
+
await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(1));
|
|
955
|
+
await client.cancelTurn("sess-1");
|
|
956
|
+
const res = await waiting;
|
|
957
|
+
expect(callOrder).toEqual(["cancelRPC", "settledPermission"]);
|
|
958
|
+
expect(res.outcome.outcome).toBe("cancelled");
|
|
959
|
+
expect(cancelSession).toHaveBeenCalledWith("sess-1");
|
|
960
|
+
expect(client.pendingPermissionCount).toBe(0);
|
|
961
|
+
});
|
|
962
|
+
it("should settle waiting permissions even if session/cancel never returns", async () => {
|
|
963
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
964
|
+
// An agent wedged on its stdin: the write goes out, nothing comes back.
|
|
965
|
+
// The permissions must still settle or their queue slots are held forever.
|
|
966
|
+
client.setSessionCanceller(() => new Promise(() => { }));
|
|
967
|
+
const waiting = client.requestPermission(params({ sessionId: "sess-1" }));
|
|
968
|
+
await vi.waitFor(() => expect(client.pendingPermissionCount).toBe(1));
|
|
969
|
+
vi.useFakeTimers();
|
|
970
|
+
try {
|
|
971
|
+
const cancelling = client.cancelTurn("sess-1");
|
|
972
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
973
|
+
await cancelling;
|
|
974
|
+
}
|
|
975
|
+
finally {
|
|
976
|
+
vi.useRealTimers();
|
|
977
|
+
}
|
|
978
|
+
expect((await waiting).outcome.outcome).toBe("cancelled");
|
|
979
|
+
expect(client.pendingPermissionCount).toBe(0);
|
|
980
|
+
errorSpy.mockRestore();
|
|
981
|
+
});
|
|
982
|
+
it("should handle cancelTurn with undefined sessionId or no canceller gracefully", async () => {
|
|
983
|
+
await expect(client.cancelTurn(undefined)).resolves.toBeUndefined();
|
|
984
|
+
const freshClient = new AgentRQACPClient(mcpBridge, () => "task-1");
|
|
985
|
+
await expect(freshClient.cancelTurn("sess-1")).resolves.toBeUndefined();
|
|
986
|
+
});
|
|
987
|
+
it("should log error if cancelSession rejects during cancelTurn", async () => {
|
|
988
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
989
|
+
client.setSessionCanceller(() => {
|
|
990
|
+
throw new Error("cancel RPC failed");
|
|
991
|
+
});
|
|
992
|
+
await client.cancelTurn("sess-1");
|
|
993
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to cancel turn for session sess-1:"), expect.any(Error));
|
|
994
|
+
errorSpy.mockRestore();
|
|
995
|
+
});
|
|
996
|
+
it("should say nothing when there is nothing waiting to cancel", () => {
|
|
997
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
998
|
+
client.cancelPendingPermissions("nothing doing");
|
|
999
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
1000
|
+
errorSpy.mockRestore();
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
describe("session mode changes", () => {
|
|
1004
|
+
it("should hand a mode change to whoever is watching for it", async () => {
|
|
1005
|
+
const onMode = vi.fn();
|
|
1006
|
+
client.setModeChangeHandler(onMode);
|
|
1007
|
+
await client.sessionUpdate({
|
|
1008
|
+
sessionId: "sess-1",
|
|
1009
|
+
update: { sessionUpdate: "current_mode_update", currentModeId: "auto" },
|
|
1010
|
+
});
|
|
1011
|
+
expect(onMode).toHaveBeenCalledWith("sess-1", "auto");
|
|
1012
|
+
});
|
|
1013
|
+
it("should not mind a mode change nobody is watching for", async () => {
|
|
1014
|
+
await expect(client.sessionUpdate({
|
|
1015
|
+
sessionId: "sess-1",
|
|
1016
|
+
update: { sessionUpdate: "current_mode_update", currentModeId: "auto" },
|
|
1017
|
+
})).resolves.toBeUndefined();
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
describe("reportStopReason", () => {
|
|
1021
|
+
function clientForTask(taskId) {
|
|
1022
|
+
return new AgentRQACPClient(mcpBridge, () => taskId);
|
|
1023
|
+
}
|
|
1024
|
+
it("should say nothing when the agent simply finished", async () => {
|
|
1025
|
+
await clientForTask("task-1").reportStopReason("sess-1", "end_turn");
|
|
1026
|
+
expect(mcpBridge.callTool).not.toHaveBeenCalled();
|
|
1027
|
+
});
|
|
1028
|
+
it("should tell the workspace when a turn was refused or cut short", async () => {
|
|
1029
|
+
const client = clientForTask("task-1");
|
|
1030
|
+
await client.reportStopReason("sess-1", "refusal");
|
|
1031
|
+
await client.reportStopReason("sess-1", "max_tokens");
|
|
1032
|
+
await client.reportStopReason("sess-1", "max_turn_requests");
|
|
1033
|
+
await client.reportStopReason("sess-1", "cancelled");
|
|
1034
|
+
const texts = mcpBridge.callTool.mock.calls.map((c) => c[1].text);
|
|
1035
|
+
expect(mcpBridge.callTool.mock.calls.every((c) => c[0] === "reply")).toBe(true);
|
|
1036
|
+
expect(texts[0]).toContain("refused");
|
|
1037
|
+
expect(texts[1]).toContain("ran out of output tokens");
|
|
1038
|
+
expect(texts[2]).toContain("model requests");
|
|
1039
|
+
expect(texts[3]).toContain("cancelled");
|
|
1040
|
+
});
|
|
1041
|
+
it("should still report a stop reason it does not recognise", async () => {
|
|
1042
|
+
await clientForTask("task-1").reportStopReason("sess-1", "something_new");
|
|
1043
|
+
expect(mcpBridge.callTool).toHaveBeenCalledWith("reply", {
|
|
1044
|
+
chatId: "task-1",
|
|
1045
|
+
text: expect.stringContaining("something_new"),
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
it("should skip a session with no task behind it", async () => {
|
|
1049
|
+
await clientForTask(undefined).reportStopReason("sess-1", "refusal");
|
|
1050
|
+
expect(mcpBridge.callTool).not.toHaveBeenCalled();
|
|
1051
|
+
});
|
|
1052
|
+
it("should survive a workspace that cannot be reached", async () => {
|
|
1053
|
+
mcpBridge.callTool.mockRejectedValue(new Error("offline"));
|
|
1054
|
+
await expect(clientForTask("task-1").reportStopReason("sess-1", "refusal")).resolves.toBeUndefined();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
868
1057
|
describe("file operations", () => {
|
|
869
1058
|
it("should read text files", async () => {
|
|
870
1059
|
vi.mocked(path.resolve).mockReturnValue("/mock/path/file.txt");
|
|
@@ -900,5 +1089,345 @@ describe("AgentRQACPClient", () => {
|
|
|
900
1089
|
})).rejects.toThrow("write failed");
|
|
901
1090
|
});
|
|
902
1091
|
});
|
|
1092
|
+
describe("streaming telemetry", () => {
|
|
1093
|
+
/** Every telemetry notification the bridge was asked to send, in order. */
|
|
1094
|
+
function telemetrySent() {
|
|
1095
|
+
return mcpBridge.sendNotification.mock.calls
|
|
1096
|
+
.filter((c) => c[0] === "notifications/claude/channel/telemetry")
|
|
1097
|
+
.map((c) => c[1]);
|
|
1098
|
+
}
|
|
1099
|
+
function newClient(taskId = "task-123") {
|
|
1100
|
+
mcpBridge.callTool = vi
|
|
1101
|
+
.fn()
|
|
1102
|
+
.mockResolvedValue({ isError: false, content: [] });
|
|
1103
|
+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
1104
|
+
return new AgentRQACPClient(mcpBridge, () => taskId);
|
|
1105
|
+
}
|
|
1106
|
+
function thought(text) {
|
|
1107
|
+
return {
|
|
1108
|
+
sessionId: "sess-1",
|
|
1109
|
+
update: {
|
|
1110
|
+
sessionUpdate: "agent_thought_chunk",
|
|
1111
|
+
content: { type: "text", text },
|
|
1112
|
+
},
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
it("batches thought chunks into one block per boundary", async () => {
|
|
1116
|
+
const c = newClient();
|
|
1117
|
+
await c.sessionUpdate(thought("Let me "));
|
|
1118
|
+
await c.sessionUpdate(thought("check the config."));
|
|
1119
|
+
// Nothing goes out mid-block: one notification per token is unusable.
|
|
1120
|
+
expect(telemetrySent()).toHaveLength(0);
|
|
1121
|
+
await c.sessionUpdate({
|
|
1122
|
+
sessionId: "sess-1",
|
|
1123
|
+
update: {
|
|
1124
|
+
sessionUpdate: "tool_call",
|
|
1125
|
+
title: "read_file",
|
|
1126
|
+
status: "pending",
|
|
1127
|
+
toolCallId: "tc-1",
|
|
1128
|
+
},
|
|
1129
|
+
});
|
|
1130
|
+
await c.flushReply("sess-1");
|
|
1131
|
+
const thoughts = telemetrySent().filter((p) => p.kind === "thought");
|
|
1132
|
+
expect(thoughts).toHaveLength(1);
|
|
1133
|
+
expect(thoughts[0]).toMatchObject({
|
|
1134
|
+
task_id: "task-123",
|
|
1135
|
+
session_id: "sess-1",
|
|
1136
|
+
kind: "thought",
|
|
1137
|
+
text: "Let me check the config.",
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
it("closes off a reasoning block when the agent starts answering", async () => {
|
|
1141
|
+
const c = newClient();
|
|
1142
|
+
await c.sessionUpdate(thought("Thinking first."));
|
|
1143
|
+
await c.sessionUpdate({
|
|
1144
|
+
sessionId: "sess-1",
|
|
1145
|
+
update: {
|
|
1146
|
+
sessionUpdate: "agent_message_chunk",
|
|
1147
|
+
content: { type: "text", text: "The answer." },
|
|
1148
|
+
},
|
|
1149
|
+
});
|
|
1150
|
+
await c.sessionUpdate(thought("Second thought."));
|
|
1151
|
+
await c.flushReply("sess-1");
|
|
1152
|
+
expect(telemetrySent()
|
|
1153
|
+
.filter((p) => p.kind === "thought")
|
|
1154
|
+
.map((p) => p.text)).toEqual(["Thinking first.", "Second thought."]);
|
|
1155
|
+
});
|
|
1156
|
+
it("keeps reasoning out of the reply the human sees", async () => {
|
|
1157
|
+
const c = newClient();
|
|
1158
|
+
await c.sessionUpdate(thought("Internal reasoning."));
|
|
1159
|
+
await c.sessionUpdate({
|
|
1160
|
+
sessionId: "sess-1",
|
|
1161
|
+
update: {
|
|
1162
|
+
sessionUpdate: "agent_message_chunk",
|
|
1163
|
+
content: { type: "text", text: "Done." },
|
|
1164
|
+
},
|
|
1165
|
+
});
|
|
1166
|
+
await c.flushReply("sess-1");
|
|
1167
|
+
expect(mcpBridge.callTool).toHaveBeenCalledWith("reply", {
|
|
1168
|
+
chatId: "task-123",
|
|
1169
|
+
text: "Done.",
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
it("ignores non-text thought chunks", async () => {
|
|
1173
|
+
const c = newClient();
|
|
1174
|
+
await c.sessionUpdate({
|
|
1175
|
+
sessionId: "sess-1",
|
|
1176
|
+
update: {
|
|
1177
|
+
sessionUpdate: "agent_thought_chunk",
|
|
1178
|
+
content: { type: "image", data: "…" },
|
|
1179
|
+
},
|
|
1180
|
+
});
|
|
1181
|
+
await c.flushReply("sess-1");
|
|
1182
|
+
expect(telemetrySent()).toHaveLength(0);
|
|
1183
|
+
});
|
|
1184
|
+
it("does not report whitespace-only reasoning", async () => {
|
|
1185
|
+
const c = newClient();
|
|
1186
|
+
await c.sessionUpdate(thought(" \n "));
|
|
1187
|
+
await c.flushReply("sess-1");
|
|
1188
|
+
expect(telemetrySent()).toHaveLength(0);
|
|
1189
|
+
});
|
|
1190
|
+
it("reports a legacy plan update as soon as it arrives", async () => {
|
|
1191
|
+
const c = newClient();
|
|
1192
|
+
await c.sessionUpdate({
|
|
1193
|
+
sessionId: "sess-1",
|
|
1194
|
+
update: {
|
|
1195
|
+
sessionUpdate: "plan",
|
|
1196
|
+
entries: [
|
|
1197
|
+
{ content: "Read the config", priority: "high", status: "completed" },
|
|
1198
|
+
{ content: "Add tests", priority: "low", status: "pending" },
|
|
1199
|
+
],
|
|
1200
|
+
},
|
|
1201
|
+
});
|
|
1202
|
+
// Plans are queued, not awaited on the stream's hot path.
|
|
1203
|
+
await c.flushReply("sess-1");
|
|
1204
|
+
expect(telemetrySent()).toEqual([
|
|
1205
|
+
{
|
|
1206
|
+
task_id: "task-123",
|
|
1207
|
+
session_id: "sess-1",
|
|
1208
|
+
kind: "plan",
|
|
1209
|
+
text: "- ✅ Read the config\n- ⬜ Add tests",
|
|
1210
|
+
data: {
|
|
1211
|
+
planId: "default",
|
|
1212
|
+
planType: "items",
|
|
1213
|
+
entries: [
|
|
1214
|
+
{
|
|
1215
|
+
content: "Read the config",
|
|
1216
|
+
priority: "high",
|
|
1217
|
+
status: "completed",
|
|
1218
|
+
},
|
|
1219
|
+
{ content: "Add tests", priority: "low", status: "pending" },
|
|
1220
|
+
],
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
]);
|
|
1224
|
+
});
|
|
1225
|
+
it("reports an ID-keyed plan update", async () => {
|
|
1226
|
+
const c = newClient();
|
|
1227
|
+
await c.sessionUpdate({
|
|
1228
|
+
sessionId: "sess-1",
|
|
1229
|
+
update: {
|
|
1230
|
+
sessionUpdate: "plan_update",
|
|
1231
|
+
plan: {
|
|
1232
|
+
type: "markdown",
|
|
1233
|
+
planId: "plan-7",
|
|
1234
|
+
content: "## Steps\n1. Ship it",
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
await c.flushReply("sess-1");
|
|
1239
|
+
expect(telemetrySent()[0]).toMatchObject({
|
|
1240
|
+
kind: "plan",
|
|
1241
|
+
text: "## Steps\n1. Ship it",
|
|
1242
|
+
data: {
|
|
1243
|
+
planId: "plan-7",
|
|
1244
|
+
planType: "markdown",
|
|
1245
|
+
content: "## Steps\n1. Ship it",
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
it("reports a withdrawn plan so the human sees it was dropped", async () => {
|
|
1250
|
+
const c = newClient();
|
|
1251
|
+
await c.sessionUpdate({
|
|
1252
|
+
sessionId: "sess-1",
|
|
1253
|
+
update: { sessionUpdate: "plan_removed", planId: "plan-7" },
|
|
1254
|
+
});
|
|
1255
|
+
await c.flushReply("sess-1");
|
|
1256
|
+
expect(telemetrySent()[0]).toMatchObject({
|
|
1257
|
+
kind: "plan",
|
|
1258
|
+
text: "Plan withdrawn.",
|
|
1259
|
+
data: { planId: "plan-7", removed: true },
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
it("puts reasoning in front of the plan it explains", async () => {
|
|
1263
|
+
const c = newClient();
|
|
1264
|
+
await c.sessionUpdate(thought("I should plan this out."));
|
|
1265
|
+
await c.sessionUpdate({
|
|
1266
|
+
sessionId: "sess-1",
|
|
1267
|
+
update: { sessionUpdate: "plan", entries: [] },
|
|
1268
|
+
});
|
|
1269
|
+
await c.sessionUpdate({
|
|
1270
|
+
sessionId: "sess-1",
|
|
1271
|
+
update: { sessionUpdate: "plan_removed", planId: "default" },
|
|
1272
|
+
});
|
|
1273
|
+
await c.flushReply("sess-1");
|
|
1274
|
+
expect(telemetrySent().map((p) => p.kind)).toEqual([
|
|
1275
|
+
"thought",
|
|
1276
|
+
"plan",
|
|
1277
|
+
"plan",
|
|
1278
|
+
]);
|
|
1279
|
+
});
|
|
1280
|
+
it("reports only the last usage snapshot, once the turn is over", async () => {
|
|
1281
|
+
const c = newClient();
|
|
1282
|
+
await c.sessionUpdate({
|
|
1283
|
+
sessionId: "sess-1",
|
|
1284
|
+
update: { sessionUpdate: "usage_update", used: 100, size: 200_000 },
|
|
1285
|
+
});
|
|
1286
|
+
await c.sessionUpdate({
|
|
1287
|
+
sessionId: "sess-1",
|
|
1288
|
+
update: {
|
|
1289
|
+
sessionUpdate: "usage_update",
|
|
1290
|
+
used: 50_000,
|
|
1291
|
+
size: 200_000,
|
|
1292
|
+
cost: { amount: 0.42, currency: "USD" },
|
|
1293
|
+
},
|
|
1294
|
+
});
|
|
1295
|
+
// Each snapshot supersedes the last, so none go out mid-turn.
|
|
1296
|
+
expect(telemetrySent()).toHaveLength(0);
|
|
1297
|
+
await c.flushReply("sess-1");
|
|
1298
|
+
expect(telemetrySent()).toEqual([
|
|
1299
|
+
{
|
|
1300
|
+
task_id: "task-123",
|
|
1301
|
+
session_id: "sess-1",
|
|
1302
|
+
kind: "usage",
|
|
1303
|
+
text: "Context 50,000 / 200,000 tokens (25%) · 0.42 USD",
|
|
1304
|
+
data: {
|
|
1305
|
+
used: 50_000,
|
|
1306
|
+
size: 200_000,
|
|
1307
|
+
percent: 25,
|
|
1308
|
+
cost: { amount: 0.42, currency: "USD" },
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
]);
|
|
1312
|
+
});
|
|
1313
|
+
it("does not report the same usage snapshot on a later turn", async () => {
|
|
1314
|
+
const c = newClient();
|
|
1315
|
+
await c.sessionUpdate({
|
|
1316
|
+
sessionId: "sess-1",
|
|
1317
|
+
update: { sessionUpdate: "usage_update", used: 100, size: 200_000 },
|
|
1318
|
+
});
|
|
1319
|
+
await c.flushReply("sess-1");
|
|
1320
|
+
mcpBridge.sendNotification.mockClear();
|
|
1321
|
+
await c.flushReply("sess-1");
|
|
1322
|
+
expect(telemetrySent()).toHaveLength(0);
|
|
1323
|
+
});
|
|
1324
|
+
it("orders reasoning, the reply, then the usage footer", async () => {
|
|
1325
|
+
const c = newClient();
|
|
1326
|
+
const order = [];
|
|
1327
|
+
mcpBridge.sendNotification = vi.fn(async (_m, p) => {
|
|
1328
|
+
order.push(p.kind);
|
|
1329
|
+
});
|
|
1330
|
+
mcpBridge.callTool = vi.fn(async () => {
|
|
1331
|
+
order.push("reply");
|
|
1332
|
+
return { isError: false, content: [] };
|
|
1333
|
+
});
|
|
1334
|
+
await c.sessionUpdate(thought("Reasoning."));
|
|
1335
|
+
await c.sessionUpdate({
|
|
1336
|
+
sessionId: "sess-1",
|
|
1337
|
+
update: {
|
|
1338
|
+
sessionUpdate: "agent_message_chunk",
|
|
1339
|
+
content: { type: "text", text: "Answer." },
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
await c.sessionUpdate({
|
|
1343
|
+
sessionId: "sess-1",
|
|
1344
|
+
update: { sessionUpdate: "usage_update", used: 1, size: 100 },
|
|
1345
|
+
});
|
|
1346
|
+
await c.flushReply("sess-1");
|
|
1347
|
+
expect(order).toEqual(["thought", "reply", "usage"]);
|
|
1348
|
+
});
|
|
1349
|
+
it("drops telemetry when the session has no task to attach it to", async () => {
|
|
1350
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
1351
|
+
const c = new AgentRQACPClient(mcpBridge, () => undefined);
|
|
1352
|
+
await c.sessionUpdate(thought("Nowhere to put this."));
|
|
1353
|
+
await c.flushReply("sess-1");
|
|
1354
|
+
expect(telemetrySent()).toHaveLength(0);
|
|
1355
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("dropping thought telemetry"));
|
|
1356
|
+
consoleSpy.mockRestore();
|
|
1357
|
+
});
|
|
1358
|
+
it("still delivers the reply when telemetry cannot be sent", async () => {
|
|
1359
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
1360
|
+
const c = newClient();
|
|
1361
|
+
mcpBridge.sendNotification.mockRejectedValue(new Error("offline"));
|
|
1362
|
+
await c.sessionUpdate(thought("Reasoning."));
|
|
1363
|
+
await c.sessionUpdate({
|
|
1364
|
+
sessionId: "sess-1",
|
|
1365
|
+
update: {
|
|
1366
|
+
sessionUpdate: "agent_message_chunk",
|
|
1367
|
+
content: { type: "text", text: "Answer." },
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
await c.flushReply("sess-1");
|
|
1371
|
+
expect(mcpBridge.callTool).toHaveBeenCalledWith("reply", {
|
|
1372
|
+
chatId: "task-123",
|
|
1373
|
+
text: "Answer.",
|
|
1374
|
+
});
|
|
1375
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to send thought telemetry"), expect.anything());
|
|
1376
|
+
consoleSpy.mockRestore();
|
|
1377
|
+
});
|
|
1378
|
+
it("keeps sending telemetry after one send has failed", async () => {
|
|
1379
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
1380
|
+
const c = newClient();
|
|
1381
|
+
mcpBridge.sendNotification
|
|
1382
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
1383
|
+
.mockResolvedValue(undefined);
|
|
1384
|
+
await c.sessionUpdate(thought("First block."));
|
|
1385
|
+
await c.sessionUpdate({
|
|
1386
|
+
sessionId: "sess-1",
|
|
1387
|
+
update: {
|
|
1388
|
+
sessionUpdate: "tool_call",
|
|
1389
|
+
title: "read_file",
|
|
1390
|
+
status: "pending",
|
|
1391
|
+
toolCallId: "tc-1",
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
await c.sessionUpdate(thought("Second block."));
|
|
1395
|
+
await c.flushReply("sess-1");
|
|
1396
|
+
expect(telemetrySent().map((p) => p.text)).toEqual([
|
|
1397
|
+
"First block.",
|
|
1398
|
+
"Second block.",
|
|
1399
|
+
]);
|
|
1400
|
+
consoleSpy.mockRestore();
|
|
1401
|
+
});
|
|
1402
|
+
it("survives a queued send that throws outright", async () => {
|
|
1403
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
1404
|
+
// Looking up the task is the gateway's own callback, and it is the one
|
|
1405
|
+
// step outside sendTelemetry's own error handling.
|
|
1406
|
+
const c = new AgentRQACPClient(mcpBridge, () => {
|
|
1407
|
+
throw new Error("task lookup blew up");
|
|
1408
|
+
});
|
|
1409
|
+
await c.sessionUpdate(thought("Reasoning."));
|
|
1410
|
+
await expect(c.flushReply("sess-1")).resolves.toBeUndefined();
|
|
1411
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Telemetry send failed"), expect.anything());
|
|
1412
|
+
consoleSpy.mockRestore();
|
|
1413
|
+
});
|
|
1414
|
+
it("keeps each session's telemetry to itself", async () => {
|
|
1415
|
+
const c = new AgentRQACPClient(mcpBridge, (sessionId) => `task-${sessionId}`);
|
|
1416
|
+
await c.sessionUpdate(thought("Session one."));
|
|
1417
|
+
await c.sessionUpdate({
|
|
1418
|
+
sessionId: "sess-2",
|
|
1419
|
+
update: {
|
|
1420
|
+
sessionUpdate: "agent_thought_chunk",
|
|
1421
|
+
content: { type: "text", text: "Session two." },
|
|
1422
|
+
},
|
|
1423
|
+
});
|
|
1424
|
+
await c.flushReply("sess-2");
|
|
1425
|
+
expect(telemetrySent()).toHaveLength(1);
|
|
1426
|
+
expect(telemetrySent()[0]).toMatchObject({
|
|
1427
|
+
task_id: "task-sess-2",
|
|
1428
|
+
text: "Session two.",
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
903
1432
|
});
|
|
904
1433
|
//# sourceMappingURL=acpClient.test.js.map
|