@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,7 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
2
3
|
import * as acp from "@agentclientprotocol/sdk";
|
|
3
|
-
import { createAcpSessionSwitcher, checkForNextTask, mapMcpServers, TaskQueue, getOrCreateSession, activeSessions, authConfig, createSessionWithAuth, isInteractiveTerminal, openAgentConnection, parseGatewayArgs, assertAgentRunnable, helpText, isRunnable, printHelp, resolveAgentCommand, runListAgents, pickHumanApprovalMode, enforceHumanApprovalMode,
|
|
4
|
+
import { createAcpSessionSwitcher, checkForNextTask, mapMcpServers, TaskQueue, getOrCreateSession, activeSessions, authConfig, createSessionWithAuth, isInteractiveTerminal, openAgentConnection, parseGatewayArgs, assertAgentRunnable, helpText, isRunnable, printHelp, resolveAgentCommand, runListAgents, pickHumanApprovalMode, enforceHumanApprovalMode, handleAgentModeChange, runAgentCommand, findActiveSession, handleTaskCancellation, cancelledTaskSeq, markTaskCancelled, isTaskCancelled, nextTaskSeq, } from "../index.js";
|
|
4
5
|
import { AUTH_REQUIRED_CODE } from "../auth.js";
|
|
6
|
+
/**
|
|
7
|
+
* A stand-in for the workspace bridge.
|
|
8
|
+
*
|
|
9
|
+
* The ACP client registers a verdict listener on it as soon as it is built, so
|
|
10
|
+
* a bare object is not enough — the real bridge is an EventEmitter.
|
|
11
|
+
*/
|
|
12
|
+
function fakeBridge() {
|
|
13
|
+
return new EventEmitter();
|
|
14
|
+
}
|
|
5
15
|
// Real emitters so the agent-process lifecycle handlers can be exercised.
|
|
6
16
|
const { spawnedAgents } = vi.hoisted(() => ({ spawnedAgents: [] }));
|
|
7
17
|
vi.mock("node:child_process", async () => {
|
|
@@ -66,6 +76,44 @@ describe("index", () => {
|
|
|
66
76
|
headers: []
|
|
67
77
|
}]);
|
|
68
78
|
});
|
|
79
|
+
it("should map an sse server as its own transport", () => {
|
|
80
|
+
const configs = [{
|
|
81
|
+
type: "sse",
|
|
82
|
+
name: "events",
|
|
83
|
+
url: "http://localhost:8000/sse",
|
|
84
|
+
}];
|
|
85
|
+
expect(mapMcpServers(configs, { mcpCapabilities: { sse: true } })).toEqual([{
|
|
86
|
+
type: "sse",
|
|
87
|
+
name: "events",
|
|
88
|
+
url: "http://localhost:8000/sse",
|
|
89
|
+
headers: [],
|
|
90
|
+
}]);
|
|
91
|
+
});
|
|
92
|
+
it("should leave out a transport the agent says it does not support", () => {
|
|
93
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
94
|
+
const configs = [
|
|
95
|
+
{ type: "http", name: "remote", url: "http://localhost:8000" },
|
|
96
|
+
{ type: "stdio", name: "local", command: "npx" },
|
|
97
|
+
];
|
|
98
|
+
// An agent may refuse the whole session/new rather than skip one entry.
|
|
99
|
+
const result = mapMcpServers(configs, {
|
|
100
|
+
mcpCapabilities: { http: false, sse: false },
|
|
101
|
+
});
|
|
102
|
+
expect(result.map((s) => s.name)).toEqual(["local"]);
|
|
103
|
+
expect(errorSpy.mock.calls.flat().join("\n")).toContain('Not passing MCP server "remote"');
|
|
104
|
+
errorSpy.mockRestore();
|
|
105
|
+
});
|
|
106
|
+
it("should still hand over a transport the agent never mentions", () => {
|
|
107
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
108
|
+
const configs = [
|
|
109
|
+
{ type: "http", name: "remote", url: "http://localhost:8000" },
|
|
110
|
+
];
|
|
111
|
+
// A terse agent is likelier than one that genuinely cannot reach HTTP,
|
|
112
|
+
// and dropping this would take the workspace's own server away from it.
|
|
113
|
+
expect(mapMcpServers(configs, {}).map((s) => s.name)).toEqual(["remote"]);
|
|
114
|
+
expect(mapMcpServers(configs).map((s) => s.name)).toEqual(["remote"]);
|
|
115
|
+
errorSpy.mockRestore();
|
|
116
|
+
});
|
|
69
117
|
it("should correctly map stdio servers", () => {
|
|
70
118
|
const configs = [{
|
|
71
119
|
type: "stdio",
|
|
@@ -328,7 +376,7 @@ describe("index", () => {
|
|
|
328
376
|
activeSessions.clear();
|
|
329
377
|
});
|
|
330
378
|
it("should spawn a new session when not cached", async () => {
|
|
331
|
-
const mockBridge =
|
|
379
|
+
const mockBridge = fakeBridge();
|
|
332
380
|
const configs = [];
|
|
333
381
|
const agentrqConfig = { env: {} };
|
|
334
382
|
const session = await getOrCreateSession("T-New", ["node", "agent.js"], configs, agentrqConfig, mockBridge);
|
|
@@ -337,7 +385,7 @@ describe("index", () => {
|
|
|
337
385
|
expect(activeSessions.has("T-New")).toBe(true);
|
|
338
386
|
});
|
|
339
387
|
it("should return cached session when already created", async () => {
|
|
340
|
-
const mockBridge =
|
|
388
|
+
const mockBridge = fakeBridge();
|
|
341
389
|
const configs = [];
|
|
342
390
|
const agentrqConfig = { env: {} };
|
|
343
391
|
const session1 = await getOrCreateSession("T-Cache", ["node", "agent.js"], configs, agentrqConfig, mockBridge);
|
|
@@ -345,7 +393,7 @@ describe("index", () => {
|
|
|
345
393
|
expect(session1).toBe(session2);
|
|
346
394
|
});
|
|
347
395
|
it("should declare elicitation support when initializing the ACP connection", async () => {
|
|
348
|
-
const mockBridge =
|
|
396
|
+
const mockBridge = fakeBridge();
|
|
349
397
|
const configs = [];
|
|
350
398
|
const agentrqConfig = { env: {} };
|
|
351
399
|
const session = await getOrCreateSession("T-Elicit", ["node", "agent.js"], configs, agentrqConfig, mockBridge);
|
|
@@ -356,7 +404,7 @@ describe("index", () => {
|
|
|
356
404
|
}));
|
|
357
405
|
});
|
|
358
406
|
it("should declare whether it can host a terminal login", async () => {
|
|
359
|
-
const mockBridge =
|
|
407
|
+
const mockBridge = fakeBridge();
|
|
360
408
|
const session = await getOrCreateSession("T-Auth-Cap", ["node", "agent.js"], [], { env: {} }, mockBridge);
|
|
361
409
|
expect(session.connection.initialize).toHaveBeenCalledWith(expect.objectContaining({
|
|
362
410
|
clientCapabilities: expect.objectContaining({
|
|
@@ -381,13 +429,13 @@ describe("index", () => {
|
|
|
381
429
|
prompt: vi.fn(),
|
|
382
430
|
};
|
|
383
431
|
});
|
|
384
|
-
const session = await getOrCreateSession("T-Login", ["node", "agent.js"], [], { env: {} },
|
|
432
|
+
const session = await getOrCreateSession("T-Login", ["node", "agent.js"], [], { env: {} }, fakeBridge());
|
|
385
433
|
expect(authenticate).toHaveBeenCalledWith({ methodId: "agent-login" });
|
|
386
434
|
expect(newSession).toHaveBeenCalledTimes(2);
|
|
387
435
|
expect(session.sessionId).toBe("authed-sess");
|
|
388
436
|
});
|
|
389
437
|
it("should drop the cached session when the agent process dies", async () => {
|
|
390
|
-
const mockBridge =
|
|
438
|
+
const mockBridge = fakeBridge();
|
|
391
439
|
await getOrCreateSession("T-Exit", ["node", "agent.js"], [], { env: {} }, mockBridge);
|
|
392
440
|
expect(activeSessions.has("T-Exit")).toBe(true);
|
|
393
441
|
spawnedAgents[spawnedAgents.length - 1].emit("exit", 1, null);
|
|
@@ -411,7 +459,7 @@ describe("index", () => {
|
|
|
411
459
|
const onExit = vi.fn();
|
|
412
460
|
const agent = await openAgentConnection({
|
|
413
461
|
acpCmdArgs: ["node", "agent.js"],
|
|
414
|
-
mcpBridge:
|
|
462
|
+
mcpBridge: fakeBridge(),
|
|
415
463
|
label: "login",
|
|
416
464
|
onExit,
|
|
417
465
|
});
|
|
@@ -450,11 +498,22 @@ describe("index", () => {
|
|
|
450
498
|
it("should default to bridging tasks with a concurrency of 2", () => {
|
|
451
499
|
expect(parseGatewayArgs([])).toEqual({
|
|
452
500
|
maxConcurrency: 2,
|
|
501
|
+
permissionTimeoutMs: 30 * 60_000,
|
|
453
502
|
command: "run",
|
|
454
503
|
allowUnverifiedAgent: false,
|
|
455
504
|
rest: [],
|
|
456
505
|
});
|
|
457
506
|
});
|
|
507
|
+
it("should take the permission timeout in minutes", () => {
|
|
508
|
+
expect(parseGatewayArgs(["--permission-timeout", "5"]).permissionTimeoutMs).toBe(300_000);
|
|
509
|
+
// Zero is a deliberate "wait indefinitely", so it must not be ignored.
|
|
510
|
+
expect(parseGatewayArgs(["--permission-timeout", "0"]).permissionTimeoutMs).toBe(0);
|
|
511
|
+
});
|
|
512
|
+
it("should keep the default when the timeout makes no sense", () => {
|
|
513
|
+
expect(parseGatewayArgs(["--permission-timeout"]).permissionTimeoutMs).toBe(30 * 60_000);
|
|
514
|
+
expect(parseGatewayArgs(["--permission-timeout", "soon"]).permissionTimeoutMs).toBe(30 * 60_000);
|
|
515
|
+
expect(parseGatewayArgs(["--permission-timeout", "-5"]).permissionTimeoutMs).toBe(30 * 60_000);
|
|
516
|
+
});
|
|
458
517
|
it("should accept both spellings of the concurrency flag", () => {
|
|
459
518
|
expect(parseGatewayArgs(["--max-concurrency", "4"]).maxConcurrency).toBe(4);
|
|
460
519
|
expect(parseGatewayArgs(["--maxConcurrency", "8"]).maxConcurrency).toBe(8);
|
|
@@ -577,6 +636,7 @@ describe("index", () => {
|
|
|
577
636
|
describe("resolveAgentCommand", () => {
|
|
578
637
|
const options = (overrides = {}) => ({
|
|
579
638
|
maxConcurrency: 2,
|
|
639
|
+
permissionTimeoutMs: 30 * 60_000,
|
|
580
640
|
command: "run",
|
|
581
641
|
allowUnverifiedAgent: false,
|
|
582
642
|
rest: [],
|
|
@@ -621,7 +681,7 @@ describe("index", () => {
|
|
|
621
681
|
await expect(resolveAgentCommand(options({ agentId: "nope" }), [], fetchImpl)).rejects.toThrow(/No agent "nope" in the ACP registry/);
|
|
622
682
|
});
|
|
623
683
|
});
|
|
624
|
-
describe("
|
|
684
|
+
describe("runAgentCommand", () => {
|
|
625
685
|
const agentrqConfig = { env: {} };
|
|
626
686
|
let logSpy;
|
|
627
687
|
let errorSpy;
|
|
@@ -645,22 +705,40 @@ describe("index", () => {
|
|
|
645
705
|
authMethods: [{ id: "agent-login", name: "Agent login" }],
|
|
646
706
|
agentCapabilities: { auth: { logout: {} } },
|
|
647
707
|
});
|
|
648
|
-
await
|
|
708
|
+
await runAgentCommand("list-auth-methods", ["gemini", "--acp"], agentrqConfig, fakeBridge());
|
|
649
709
|
const printed = logSpy.mock.calls.flat().join("\n");
|
|
650
710
|
expect(printed).toContain("Agent login (agent-login)");
|
|
651
711
|
expect(printed).toContain("--logout");
|
|
652
712
|
expect(spawnedAgents[0].kill).toHaveBeenCalled();
|
|
653
713
|
});
|
|
714
|
+
it("should print what the agent says it supports", async () => {
|
|
715
|
+
mockConnection({}, {
|
|
716
|
+
protocolVersion: 1,
|
|
717
|
+
agentCapabilities: {
|
|
718
|
+
loadSession: true,
|
|
719
|
+
sessionCapabilities: { resume: {} },
|
|
720
|
+
mcpCapabilities: { http: true },
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
await runAgentCommand("agent-info", ["gemini", "--acp"], agentrqConfig, fakeBridge());
|
|
724
|
+
const printed = String(logSpy.mock.calls.at(-1)[0]);
|
|
725
|
+
expect(printed).toContain("gemini --acp");
|
|
726
|
+
expect(printed).toContain("ACP protocol version 1");
|
|
727
|
+
expect(printed).toContain("session/resume yes");
|
|
728
|
+
expect(printed).toContain("session/close no");
|
|
729
|
+
expect(printed).toContain("http yes");
|
|
730
|
+
expect(spawnedAgents[0].kill).toHaveBeenCalled();
|
|
731
|
+
});
|
|
654
732
|
it("should report agents that advertise no login", async () => {
|
|
655
733
|
mockConnection({});
|
|
656
|
-
await
|
|
734
|
+
await runAgentCommand("list-auth-methods", ["gemini", "--acp"], agentrqConfig, fakeBridge());
|
|
657
735
|
expect(logSpy.mock.calls.flat().join("\n")).toContain("no authentication methods");
|
|
658
736
|
});
|
|
659
737
|
it("should log out", async () => {
|
|
660
738
|
const connection = mockConnection({ logout: vi.fn().mockResolvedValue({}) }, {
|
|
661
739
|
agentCapabilities: { auth: { logout: {} } },
|
|
662
740
|
});
|
|
663
|
-
await
|
|
741
|
+
await runAgentCommand("logout", ["gemini", "--acp"], agentrqConfig, fakeBridge());
|
|
664
742
|
expect(connection.logout).toHaveBeenCalledWith({});
|
|
665
743
|
});
|
|
666
744
|
it("should log in with the method the user named", async () => {
|
|
@@ -670,14 +748,14 @@ describe("index", () => {
|
|
|
670
748
|
{ id: "oauth", name: "OAuth" },
|
|
671
749
|
],
|
|
672
750
|
});
|
|
673
|
-
await
|
|
751
|
+
await runAgentCommand("login", ["gemini", "--acp"], agentrqConfig, fakeBridge(), "oauth");
|
|
674
752
|
expect(connection.authenticate).toHaveBeenCalledWith({ methodId: "oauth" });
|
|
675
753
|
});
|
|
676
754
|
it("should still shut the agent down when the login fails", async () => {
|
|
677
755
|
mockConnection({ authenticate: vi.fn() }, {
|
|
678
756
|
authMethods: [{ id: "agent-login", name: "Agent login" }],
|
|
679
757
|
});
|
|
680
|
-
await expect(
|
|
758
|
+
await expect(runAgentCommand("login", ["gemini", "--acp"], agentrqConfig, fakeBridge(), "missing")).rejects.toThrow(/Unknown authentication method/);
|
|
681
759
|
expect(spawnedAgents[0].kill).toHaveBeenCalled();
|
|
682
760
|
});
|
|
683
761
|
});
|
|
@@ -690,6 +768,7 @@ describe("index", () => {
|
|
|
690
768
|
expect(new Set(documented)).toEqual(new Set([
|
|
691
769
|
"--agent",
|
|
692
770
|
"--list-agents",
|
|
771
|
+
"--agent-info",
|
|
693
772
|
"--allow-unverified-agent",
|
|
694
773
|
"--registry-url",
|
|
695
774
|
"--list-auth-methods",
|
|
@@ -697,6 +776,7 @@ describe("index", () => {
|
|
|
697
776
|
"--logout",
|
|
698
777
|
"--auth-method",
|
|
699
778
|
"--max-concurrency",
|
|
779
|
+
"--permission-timeout",
|
|
700
780
|
"--help",
|
|
701
781
|
]));
|
|
702
782
|
});
|
|
@@ -875,5 +955,193 @@ describe("index", () => {
|
|
|
875
955
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to set session mode"), expect.any(Error));
|
|
876
956
|
});
|
|
877
957
|
});
|
|
958
|
+
describe("task cancellation handling", () => {
|
|
959
|
+
let errorSpy;
|
|
960
|
+
beforeEach(() => {
|
|
961
|
+
activeSessions.clear();
|
|
962
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
963
|
+
});
|
|
964
|
+
afterEach(() => {
|
|
965
|
+
activeSessions.clear();
|
|
966
|
+
errorSpy.mockRestore();
|
|
967
|
+
});
|
|
968
|
+
describe("findActiveSession", () => {
|
|
969
|
+
it("should find session by direct task key", () => {
|
|
970
|
+
const mockSession = { sessionId: "sess-1", acpClient: { cancelTurn: vi.fn() } };
|
|
971
|
+
activeSessions.set("task-1", mockSession);
|
|
972
|
+
expect(findActiveSession("task-1")).toBe(mockSession);
|
|
973
|
+
});
|
|
974
|
+
it("should find session by sessionId match", () => {
|
|
975
|
+
const mockSession = { sessionId: "sess-custom", acpClient: { cancelTurn: vi.fn() } };
|
|
976
|
+
activeSessions.set("key-other", mockSession);
|
|
977
|
+
expect(findActiveSession("sess-custom")).toBe(mockSession);
|
|
978
|
+
});
|
|
979
|
+
it("should return the single session if no taskId is provided", () => {
|
|
980
|
+
const mockSession = { sessionId: "sess-only", acpClient: { cancelTurn: vi.fn() } };
|
|
981
|
+
activeSessions.set("default", mockSession);
|
|
982
|
+
expect(findActiveSession(undefined)).toBe(mockSession);
|
|
983
|
+
});
|
|
984
|
+
it("should fallback to default keyed session when taskId is given but only 1 session exists", () => {
|
|
985
|
+
const mockSession = { sessionId: "s1", acpClient: { cancelTurn: vi.fn() } };
|
|
986
|
+
activeSessions.set("default", mockSession);
|
|
987
|
+
expect(findActiveSession("task-new-id")).toBe(mockSession);
|
|
988
|
+
});
|
|
989
|
+
it("should not fall back to a session that belongs to a different task", () => {
|
|
990
|
+
// Task A is running; a cancel for queued task B must not abort it.
|
|
991
|
+
const sessionA = { sessionId: "sess-a", acpClient: { cancelTurn: vi.fn() } };
|
|
992
|
+
activeSessions.set("task-a", sessionA);
|
|
993
|
+
expect(findActiveSession("task-b")).toBeUndefined();
|
|
994
|
+
});
|
|
995
|
+
it("should return undefined if taskId is not found when multiple sessions exist", () => {
|
|
996
|
+
activeSessions.set("task-1", { sessionId: "s1" });
|
|
997
|
+
activeSessions.set("task-2", { sessionId: "s2" });
|
|
998
|
+
expect(findActiveSession("task-nonexistent")).toBeUndefined();
|
|
999
|
+
expect(findActiveSession(undefined)).toBeUndefined();
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
describe("handleTaskCancellation", () => {
|
|
1003
|
+
it("should cancel turn on the matched active session", async () => {
|
|
1004
|
+
const cancelTurn = vi.fn().mockResolvedValue(undefined);
|
|
1005
|
+
const mockSession = {
|
|
1006
|
+
sessionId: "sess-100",
|
|
1007
|
+
acpClient: { cancelTurn },
|
|
1008
|
+
};
|
|
1009
|
+
activeSessions.set("task-100", mockSession);
|
|
1010
|
+
await handleTaskCancellation("task-100", "cancelled by user");
|
|
1011
|
+
expect(cancelTurn).toHaveBeenCalledWith("sess-100");
|
|
1012
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Cancelling session sess-100 for task task-100 (cancelled by user)"));
|
|
1013
|
+
});
|
|
1014
|
+
it("should skip a task cancelled while it was still being fetched", async () => {
|
|
1015
|
+
cancelledTaskSeq.clear();
|
|
1016
|
+
const promptMock = vi.fn();
|
|
1017
|
+
const switcher = { ensureForTask: vi.fn().mockResolvedValue("s-q1") };
|
|
1018
|
+
const acpClient = { flushReply: vi.fn(), reportStopReason: vi.fn() };
|
|
1019
|
+
const connection = { prompt: promptMock };
|
|
1020
|
+
const bridge = {
|
|
1021
|
+
// The cancel lands while the poll is in flight — the task has no
|
|
1022
|
+
// session yet, so nothing but this flag can stop it.
|
|
1023
|
+
callTool: vi.fn().mockImplementation(async () => {
|
|
1024
|
+
await handleTaskCancellation("task-queued-1");
|
|
1025
|
+
return { content: [{ type: "text", text: "task task-queued-1: do work" }] };
|
|
1026
|
+
}),
|
|
1027
|
+
};
|
|
1028
|
+
await checkForNextTask(bridge, connection, switcher, acpClient);
|
|
1029
|
+
expect(promptMock).not.toHaveBeenCalled();
|
|
1030
|
+
});
|
|
1031
|
+
it("should not swallow work queued after the cancellation", async () => {
|
|
1032
|
+
// agentrq reuses a chat's id as the task id, so the id cancelled a
|
|
1033
|
+
// moment ago is the same id the user's next message arrives under.
|
|
1034
|
+
cancelledTaskSeq.clear();
|
|
1035
|
+
await handleTaskCancellation("task-queued-2");
|
|
1036
|
+
const promptMock = vi.fn().mockResolvedValue({ stopReason: "end_turn" });
|
|
1037
|
+
const switcher = { ensureForTask: vi.fn().mockResolvedValue("s-q2") };
|
|
1038
|
+
const acpClient = { flushReply: vi.fn(), reportStopReason: vi.fn() };
|
|
1039
|
+
const connection = { prompt: promptMock };
|
|
1040
|
+
const bridge = {
|
|
1041
|
+
callTool: vi.fn().mockResolvedValue({
|
|
1042
|
+
content: [{ type: "text", text: "task task-queued-2: fresh work" }],
|
|
1043
|
+
}),
|
|
1044
|
+
};
|
|
1045
|
+
await checkForNextTask(bridge, connection, switcher, acpClient);
|
|
1046
|
+
expect(promptMock).toHaveBeenCalled();
|
|
1047
|
+
});
|
|
1048
|
+
it("should forget the oldest cancellations rather than grow forever", async () => {
|
|
1049
|
+
cancelledTaskSeq.clear();
|
|
1050
|
+
for (let i = 0; i < 250; i++)
|
|
1051
|
+
markTaskCancelled(`task-${i}`);
|
|
1052
|
+
expect(cancelledTaskSeq.size).toBe(200);
|
|
1053
|
+
expect(cancelledTaskSeq.has("task-0")).toBe(false);
|
|
1054
|
+
expect(cancelledTaskSeq.has("task-249")).toBe(true);
|
|
1055
|
+
});
|
|
1056
|
+
it("should only stop the notification that was queued before the cancel", async () => {
|
|
1057
|
+
cancelledTaskSeq.clear();
|
|
1058
|
+
const older = nextTaskSeq();
|
|
1059
|
+
await handleTaskCancellation("task-two-turns");
|
|
1060
|
+
const newer = nextTaskSeq();
|
|
1061
|
+
expect(isTaskCancelled("task-two-turns", older)).toBe(true);
|
|
1062
|
+
expect(isTaskCancelled("task-two-turns", newer)).toBe(false);
|
|
1063
|
+
});
|
|
1064
|
+
it("should log when task to cancel is not found", async () => {
|
|
1065
|
+
await handleTaskCancellation("task-missing");
|
|
1066
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Received cancellation for task task-missing, but no active session found"));
|
|
1067
|
+
});
|
|
1068
|
+
it("should cancel the single session when taskId is omitted and only 1 session exists", async () => {
|
|
1069
|
+
const cancel1 = vi.fn().mockResolvedValue(undefined);
|
|
1070
|
+
activeSessions.set("t1", { sessionId: "s1", acpClient: { cancelTurn: cancel1 } });
|
|
1071
|
+
await handleTaskCancellation(undefined, "single shutdown");
|
|
1072
|
+
expect(cancel1).toHaveBeenCalledWith("s1");
|
|
1073
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Received cancellation with no taskId (single shutdown). Cancelling active session s1..."));
|
|
1074
|
+
});
|
|
1075
|
+
it("should fail closed and not cancel anything when taskId is omitted and multiple sessions exist", async () => {
|
|
1076
|
+
const cancel1 = vi.fn().mockResolvedValue(undefined);
|
|
1077
|
+
const cancel2 = vi.fn().mockResolvedValue(undefined);
|
|
1078
|
+
activeSessions.set("t1", { sessionId: "s1", acpClient: { cancelTurn: cancel1 } });
|
|
1079
|
+
activeSessions.set("t2", { sessionId: "s2", acpClient: { cancelTurn: cancel2 } });
|
|
1080
|
+
await handleTaskCancellation(undefined, "multiple active");
|
|
1081
|
+
expect(cancel1).not.toHaveBeenCalled();
|
|
1082
|
+
expect(cancel2).not.toHaveBeenCalled();
|
|
1083
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("but 2 sessions are active (skipping to avoid aborting unrelated tasks)"));
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
describe("handleAgentModeChange", () => {
|
|
1088
|
+
const modes = {
|
|
1089
|
+
currentModeId: "ask",
|
|
1090
|
+
availableModes: [
|
|
1091
|
+
{ id: "ask", name: "Ask first" },
|
|
1092
|
+
{ id: "auto", name: "Auto approve" },
|
|
1093
|
+
],
|
|
1094
|
+
};
|
|
1095
|
+
let errorSpy;
|
|
1096
|
+
beforeEach(() => {
|
|
1097
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
1098
|
+
});
|
|
1099
|
+
afterEach(() => errorSpy.mockRestore());
|
|
1100
|
+
it("should leave a mode that still asks the human alone", async () => {
|
|
1101
|
+
const connection = { setSessionMode: vi.fn() };
|
|
1102
|
+
await handleAgentModeChange(connection, "sess-ok", "ask", modes);
|
|
1103
|
+
expect(connection.setSessionMode).not.toHaveBeenCalled();
|
|
1104
|
+
});
|
|
1105
|
+
it("should put the session back when the agent starts approving for us", async () => {
|
|
1106
|
+
const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
|
|
1107
|
+
await handleAgentModeChange(connection, "sess-drift", "auto", modes);
|
|
1108
|
+
expect(connection.setSessionMode).toHaveBeenCalledWith({
|
|
1109
|
+
sessionId: "sess-drift",
|
|
1110
|
+
modeId: "ask",
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
it("should treat a mode the agent never advertised as untrusted", async () => {
|
|
1114
|
+
const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
|
|
1115
|
+
await handleAgentModeChange(connection, "sess-unknown", "something-new", modes);
|
|
1116
|
+
expect(connection.setSessionMode).toHaveBeenCalledWith({
|
|
1117
|
+
sessionId: "sess-unknown",
|
|
1118
|
+
modeId: "ask",
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
it("should stop fighting an agent that keeps switching back", async () => {
|
|
1122
|
+
const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
|
|
1123
|
+
for (let i = 0; i < 5; i++) {
|
|
1124
|
+
await handleAgentModeChange(connection, "sess-stubborn", "auto", modes);
|
|
1125
|
+
}
|
|
1126
|
+
// An unbounded fight would be an endless stream of setSessionMode calls.
|
|
1127
|
+
expect(connection.setSessionMode).toHaveBeenCalledTimes(3);
|
|
1128
|
+
expect(errorSpy.mock.calls.flat().join("\n")).toContain("Giving up after 3 attempts");
|
|
1129
|
+
});
|
|
1130
|
+
it("should start counting again once the session is back in a safe mode", async () => {
|
|
1131
|
+
const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
|
|
1132
|
+
await handleAgentModeChange(connection, "sess-recovered", "auto", modes);
|
|
1133
|
+
await handleAgentModeChange(connection, "sess-recovered", "ask", modes);
|
|
1134
|
+
for (let i = 0; i < 4; i++) {
|
|
1135
|
+
await handleAgentModeChange(connection, "sess-recovered", "auto", modes);
|
|
1136
|
+
}
|
|
1137
|
+
expect(connection.setSessionMode).toHaveBeenCalledTimes(4);
|
|
1138
|
+
});
|
|
1139
|
+
it("should do nothing for an agent that offers no modes at all", async () => {
|
|
1140
|
+
const connection = { setSessionMode: vi.fn() };
|
|
1141
|
+
await handleAgentModeChange(connection, "sess-modeless", "whatever", undefined);
|
|
1142
|
+
await handleAgentModeChange(connection, "sess-modeless", "whatever", { availableModes: [] });
|
|
1143
|
+
expect(connection.setSessionMode).not.toHaveBeenCalled();
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
878
1146
|
});
|
|
879
1147
|
//# sourceMappingURL=index.test.js.map
|