@agentrq/acp-gateway 0.2.5 → 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 CHANGED
@@ -42,7 +42,7 @@ npm install -g @agentrq/acp-gateway
42
42
 
43
43
  ## Current Version
44
44
 
45
- `0.2.5`
45
+ `0.2.6`
46
46
 
47
47
  ## Usage
48
48
 
@@ -252,6 +252,7 @@ acp-gateway/
252
252
  │ ├── index.ts # Entry point & orchestrator
253
253
  │ ├── mcpClient.ts # MCP Bridge with auto-reconnect
254
254
  │ ├── registry.ts # ACP registry index client
255
+ │ ├── telemetry.ts # Thought / plan / usage rendering
255
256
  │ └── __tests__/ # Unit tests
256
257
  ├── package.json
257
258
  └── tsconfig.json
@@ -262,6 +263,7 @@ acp-gateway/
262
263
  - **Auto-reconnection**: The MCP transport auto-reconnects on disconnection with exponential backoff (1s → 30s max).
263
264
  - **Notification-driven tasks**: The MCP server pushes task content via `notifications/claude/channel`; `acp-gateway` reacts immediately.
264
265
  - **Permission flow**: ACP agent requests permission → `acp-gateway` forwards to MCP server → waits for verdict → resolves the ACP permission.
266
+ - **Streaming telemetry**: Reasoning, execution plans and token/cost counters are forwarded on `notifications/claude/channel/telemetry`. Reasoning is batched into one block per boundary (the agent starts answering, calls a tool, or revises its plan) rather than one message per token; plans go out as they change; only the last usage snapshot of a turn is reported. Sends are queued off the ACP stream, so a workspace that is slow or unreachable never stalls the agent.
265
267
  - **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
268
  - **Authentication**: Login methods come from the `initialize` handshake; an `auth_required` refusal triggers a login and one retry of `newSession`.
267
269
  - **File I/O**: `readTextFile` / `writeTextFile` are proxied directly to the filesystem; paths are resolved relative to `process.cwd()`.
@@ -918,6 +918,81 @@ describe("AgentRQACPClient", () => {
918
918
  client.cancelPendingPermissions("test over");
919
919
  await waiting;
920
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
+ });
921
996
  it("should say nothing when there is nothing waiting to cancel", () => {
922
997
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
923
998
  client.cancelPendingPermissions("nothing doing");
@@ -1014,5 +1089,345 @@ describe("AgentRQACPClient", () => {
1014
1089
  })).rejects.toThrow("write failed");
1015
1090
  });
1016
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
+ });
1017
1432
  });
1018
1433
  //# sourceMappingURL=acpClient.test.js.map