@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.
@@ -1,13 +1,30 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { Writable, Readable } from "node:stream";
3
- import { createAcpSessionSwitcher, checkForNextTask, mapMcpServers, TaskQueue, getOrCreateSession, activeSessions, pickHumanApprovalMode, enforceHumanApprovalMode, } from "../index.js";
4
- vi.mock("node:child_process", () => {
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import * as acp from "@agentclientprotocol/sdk";
4
+ import { createAcpSessionSwitcher, checkForNextTask, mapMcpServers, TaskQueue, getOrCreateSession, activeSessions, authConfig, createSessionWithAuth, isInteractiveTerminal, openAgentConnection, parseGatewayArgs, assertAgentRunnable, helpText, isRunnable, printHelp, resolveAgentCommand, runListAgents, pickHumanApprovalMode, enforceHumanApprovalMode, handleAgentModeChange, runAgentCommand, } from "../index.js";
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
+ }
15
+ // Real emitters so the agent-process lifecycle handlers can be exercised.
16
+ const { spawnedAgents } = vi.hoisted(() => ({ spawnedAgents: [] }));
17
+ vi.mock("node:child_process", async () => {
18
+ const { EventEmitter } = await import("node:events");
19
+ const { Writable, Readable } = await import("node:stream");
5
20
  return {
6
- spawn: vi.fn().mockReturnValue({
7
- stdin: new Writable({ write(chunk, encoding, callback) { callback(); } }),
8
- stdout: new Readable({ read() { this.push(null); } }),
9
- kill: vi.fn(),
10
- on: vi.fn(),
21
+ spawn: vi.fn(() => {
22
+ const child = new EventEmitter();
23
+ child.stdin = new Writable({ write(chunk, encoding, callback) { callback(); } });
24
+ child.stdout = new Readable({ read() { this.push(null); } });
25
+ child.kill = vi.fn();
26
+ spawnedAgents.push(child);
27
+ return child;
11
28
  }),
12
29
  };
13
30
  });
@@ -59,6 +76,44 @@ describe("index", () => {
59
76
  headers: []
60
77
  }]);
61
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
+ });
62
117
  it("should correctly map stdio servers", () => {
63
118
  const configs = [{
64
119
  type: "stdio",
@@ -321,7 +376,7 @@ describe("index", () => {
321
376
  activeSessions.clear();
322
377
  });
323
378
  it("should spawn a new session when not cached", async () => {
324
- const mockBridge = {};
379
+ const mockBridge = fakeBridge();
325
380
  const configs = [];
326
381
  const agentrqConfig = { env: {} };
327
382
  const session = await getOrCreateSession("T-New", ["node", "agent.js"], configs, agentrqConfig, mockBridge);
@@ -330,7 +385,7 @@ describe("index", () => {
330
385
  expect(activeSessions.has("T-New")).toBe(true);
331
386
  });
332
387
  it("should return cached session when already created", async () => {
333
- const mockBridge = {};
388
+ const mockBridge = fakeBridge();
334
389
  const configs = [];
335
390
  const agentrqConfig = { env: {} };
336
391
  const session1 = await getOrCreateSession("T-Cache", ["node", "agent.js"], configs, agentrqConfig, mockBridge);
@@ -338,7 +393,7 @@ describe("index", () => {
338
393
  expect(session1).toBe(session2);
339
394
  });
340
395
  it("should declare elicitation support when initializing the ACP connection", async () => {
341
- const mockBridge = {};
396
+ const mockBridge = fakeBridge();
342
397
  const configs = [];
343
398
  const agentrqConfig = { env: {} };
344
399
  const session = await getOrCreateSession("T-Elicit", ["node", "agent.js"], configs, agentrqConfig, mockBridge);
@@ -348,6 +403,424 @@ describe("index", () => {
348
403
  }),
349
404
  }));
350
405
  });
406
+ it("should declare whether it can host a terminal login", async () => {
407
+ const mockBridge = fakeBridge();
408
+ const session = await getOrCreateSession("T-Auth-Cap", ["node", "agent.js"], [], { env: {} }, mockBridge);
409
+ expect(session.connection.initialize).toHaveBeenCalledWith(expect.objectContaining({
410
+ clientCapabilities: expect.objectContaining({
411
+ auth: { terminal: isInteractiveTerminal() },
412
+ }),
413
+ }));
414
+ });
415
+ it("should log in and retry when the agent refuses the session", async () => {
416
+ const authenticate = vi.fn().mockResolvedValue({});
417
+ const newSession = vi
418
+ .fn()
419
+ .mockRejectedValueOnce(Object.assign(new Error("Authentication required"), { code: AUTH_REQUIRED_CODE }))
420
+ .mockResolvedValue({ sessionId: "authed-sess" });
421
+ vi.mocked(acp.ClientSideConnection).mockImplementationOnce(function () {
422
+ return {
423
+ initialize: vi.fn().mockResolvedValue({
424
+ protocolVersion: "0.1.0",
425
+ authMethods: [{ id: "agent-login", name: "Agent login" }],
426
+ }),
427
+ newSession,
428
+ authenticate,
429
+ prompt: vi.fn(),
430
+ };
431
+ });
432
+ const session = await getOrCreateSession("T-Login", ["node", "agent.js"], [], { env: {} }, fakeBridge());
433
+ expect(authenticate).toHaveBeenCalledWith({ methodId: "agent-login" });
434
+ expect(newSession).toHaveBeenCalledTimes(2);
435
+ expect(session.sessionId).toBe("authed-sess");
436
+ });
437
+ it("should drop the cached session when the agent process dies", async () => {
438
+ const mockBridge = fakeBridge();
439
+ await getOrCreateSession("T-Exit", ["node", "agent.js"], [], { env: {} }, mockBridge);
440
+ expect(activeSessions.has("T-Exit")).toBe(true);
441
+ spawnedAgents[spawnedAgents.length - 1].emit("exit", 1, null);
442
+ expect(activeSessions.has("T-Exit")).toBe(false);
443
+ });
444
+ });
445
+ describe("openAgentConnection", () => {
446
+ beforeEach(() => {
447
+ spawnedAgents.length = 0;
448
+ });
449
+ it("should report the agent's login methods and survive process failures", async () => {
450
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
451
+ vi.mocked(acp.ClientSideConnection).mockImplementationOnce(function () {
452
+ return {
453
+ initialize: vi.fn().mockResolvedValue({
454
+ protocolVersion: "0.1.0",
455
+ authMethods: [{ id: "agent-login", name: "Agent login" }],
456
+ }),
457
+ };
458
+ });
459
+ const onExit = vi.fn();
460
+ const agent = await openAgentConnection({
461
+ acpCmdArgs: ["node", "agent.js"],
462
+ mcpBridge: fakeBridge(),
463
+ label: "login",
464
+ onExit,
465
+ });
466
+ expect(agent.initResult.authMethods).toHaveLength(1);
467
+ expect(errorSpy.mock.calls.flat().join("\n")).toContain("Agent login (agent-login)");
468
+ agent.process.emit("error", new Error("spawn failed"));
469
+ agent.process.stdin.emit("error", new Error("EPIPE"));
470
+ expect(onExit).toHaveBeenCalledTimes(1);
471
+ errorSpy.mockRestore();
472
+ });
473
+ });
474
+ describe("createSessionWithAuth", () => {
475
+ const auth = {
476
+ methods: [{ id: "agent-login", name: "Agent login" }],
477
+ launch: { command: "node", args: ["agent.js"] },
478
+ };
479
+ it("should start the session directly when no login is needed", async () => {
480
+ const connection = {
481
+ newSession: vi.fn().mockResolvedValue({ sessionId: "s1" }),
482
+ authenticate: vi.fn(),
483
+ };
484
+ const result = await createSessionWithAuth(connection, {}, auth);
485
+ expect(result.sessionId).toBe("s1");
486
+ expect(connection.authenticate).not.toHaveBeenCalled();
487
+ });
488
+ it("should surface failures that are not about authentication", async () => {
489
+ const connection = {
490
+ newSession: vi.fn().mockRejectedValue(new Error("cwd does not exist")),
491
+ authenticate: vi.fn(),
492
+ };
493
+ await expect(createSessionWithAuth(connection, {}, auth)).rejects.toThrow("cwd does not exist");
494
+ expect(connection.authenticate).not.toHaveBeenCalled();
495
+ });
496
+ });
497
+ describe("parseGatewayArgs", () => {
498
+ it("should default to bridging tasks with a concurrency of 2", () => {
499
+ expect(parseGatewayArgs([])).toEqual({
500
+ maxConcurrency: 2,
501
+ permissionTimeoutMs: 30 * 60_000,
502
+ command: "run",
503
+ allowUnverifiedAgent: false,
504
+ rest: [],
505
+ });
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
+ });
517
+ it("should accept both spellings of the concurrency flag", () => {
518
+ expect(parseGatewayArgs(["--max-concurrency", "4"]).maxConcurrency).toBe(4);
519
+ expect(parseGatewayArgs(["--maxConcurrency", "8"]).maxConcurrency).toBe(8);
520
+ });
521
+ it("should keep the default when the concurrency value is missing or not a number", () => {
522
+ expect(parseGatewayArgs(["--max-concurrency"]).maxConcurrency).toBe(2);
523
+ expect(parseGatewayArgs(["--max-concurrency", "many"]).maxConcurrency).toBe(2);
524
+ expect(parseGatewayArgs(["--max-concurrency", "--logout"])).toMatchObject({
525
+ maxConcurrency: 2,
526
+ command: "logout",
527
+ });
528
+ });
529
+ it("should read the preferred auth method", () => {
530
+ expect(parseGatewayArgs(["--auth-method", "oauth"])).toMatchObject({
531
+ maxConcurrency: 2,
532
+ command: "run",
533
+ authMethodId: "oauth",
534
+ });
535
+ expect(parseGatewayArgs(["--auth-method"]).authMethodId).toBeUndefined();
536
+ });
537
+ it("should recognise the auth commands", () => {
538
+ expect(parseGatewayArgs(["--login"])).toMatchObject({ maxConcurrency: 2, command: "login" });
539
+ expect(parseGatewayArgs(["--login", "oauth"])).toMatchObject({
540
+ maxConcurrency: 2,
541
+ command: "login",
542
+ authMethodId: "oauth",
543
+ });
544
+ expect(parseGatewayArgs(["--logout"]).command).toBe("logout");
545
+ expect(parseGatewayArgs(["--list-auth-methods"]).command).toBe("list-auth-methods");
546
+ });
547
+ it("should keep tokens it does not recognise as the agent command", () => {
548
+ const options = parseGatewayArgs(["--verbose", "--login"]);
549
+ expect(options.command).toBe("login");
550
+ expect(options.rest).toEqual(["--verbose"]);
551
+ });
552
+ it("should collect an agent command given without a -- separator", () => {
553
+ expect(parseGatewayArgs(["--max-concurrency", "4", "gemini", "--acp"])).toMatchObject({
554
+ maxConcurrency: 4,
555
+ rest: ["gemini", "--acp"],
556
+ });
557
+ });
558
+ it("should read the registry options", () => {
559
+ expect(parseGatewayArgs(["--agent", "gemini"])).toMatchObject({
560
+ agentId: "gemini",
561
+ command: "run",
562
+ });
563
+ expect(parseGatewayArgs(["--agent"]).agentId).toBeUndefined();
564
+ expect(parseGatewayArgs(["--list-agents"]).command).toBe("list-agents");
565
+ expect(parseGatewayArgs(["--allow-unverified-agent"]).allowUnverifiedAgent).toBe(true);
566
+ expect(parseGatewayArgs(["--registry-url", "http://localhost/registry.json"]).registryUrl).toBe("http://localhost/registry.json");
567
+ expect(parseGatewayArgs(["--registry-url"]).registryUrl).toBeUndefined();
568
+ });
569
+ it("should recognise both spellings of the help flag", () => {
570
+ expect(parseGatewayArgs(["--help"]).command).toBe("help");
571
+ expect(parseGatewayArgs(["-h"]).command).toBe("help");
572
+ });
573
+ });
574
+ describe("isRunnable", () => {
575
+ it("finds a command on PATH", () => {
576
+ expect(isRunnable("node", { PATH: process.env.PATH }, process.platform)).toBe(true);
577
+ });
578
+ it("does not find one that is not there", () => {
579
+ expect(isRunnable("acp-gateway-no-such-command", { PATH: process.env.PATH })).toBe(false);
580
+ expect(isRunnable("node", { PATH: "" })).toBe(false);
581
+ expect(isRunnable("node", {})).toBe(false);
582
+ });
583
+ it("checks a path directly rather than searching PATH", () => {
584
+ expect(isRunnable(process.execPath, {})).toBe(true);
585
+ expect(isRunnable("/no/such/agent", {})).toBe(false);
586
+ });
587
+ it("honours PATHEXT and backslashes on Windows", () => {
588
+ // A bare name on Windows resolves through PATHEXT, so "npx" alone finds
589
+ // nothing while "npx.cmd" would.
590
+ expect(isRunnable("npx", { PATH: "C:\\tools", PATHEXT: ".EXE" }, "win32")).toBe(false);
591
+ expect(isRunnable("C:\\nope\\agent.exe", {}, "win32")).toBe(false);
592
+ });
593
+ });
594
+ describe("assertAgentRunnable", () => {
595
+ it("accepts a command that exists", () => {
596
+ expect(() => assertAgentRunnable("node", false)).not.toThrow();
597
+ });
598
+ it("suggests --agent when the command looks like a registry id", () => {
599
+ expect(() => assertAgentRunnable("antigravity-acp", false)).toThrow(/run it with --agent antigravity-acp/);
600
+ });
601
+ it("blames the registry when the id was already resolved", () => {
602
+ expect(() => assertAgentRunnable("acp-gateway-no-such-runner", true)).toThrow(/The registry says to run it as "acp-gateway-no-such-runner", which is not installed/);
603
+ });
604
+ });
605
+ describe("runListAgents", () => {
606
+ const registry = {
607
+ version: "1.0.0",
608
+ agents: [
609
+ {
610
+ id: "gemini",
611
+ name: "Gemini CLI",
612
+ version: "0.58.0",
613
+ description: "Google's CLI",
614
+ distribution: { npx: { package: "@google/gemini-cli", args: ["--acp"] } },
615
+ },
616
+ ],
617
+ };
618
+ it("should print every agent and how to run one", async () => {
619
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
620
+ const fetchImpl = vi.fn().mockResolvedValue({ ok: true, json: async () => registry });
621
+ await runListAgents(undefined, fetchImpl);
622
+ const printed = logSpy.mock.calls.flat().join("\n");
623
+ logSpy.mockRestore();
624
+ expect(printed).toContain("ACP registry v1.0.0 — 1 agents");
625
+ expect(printed).toContain("gemini");
626
+ expect(printed).toContain("acp-gateway --agent <id>");
627
+ });
628
+ it("should read a registry the user pointed it at", async () => {
629
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
630
+ const fetchImpl = vi.fn().mockResolvedValue({ ok: true, json: async () => registry });
631
+ await runListAgents("http://localhost/registry.json", fetchImpl);
632
+ logSpy.mockRestore();
633
+ expect(fetchImpl).toHaveBeenCalledWith("http://localhost/registry.json");
634
+ });
635
+ });
636
+ describe("resolveAgentCommand", () => {
637
+ const options = (overrides = {}) => ({
638
+ maxConcurrency: 2,
639
+ permissionTimeoutMs: 30 * 60_000,
640
+ command: "run",
641
+ allowUnverifiedAgent: false,
642
+ rest: [],
643
+ ...overrides,
644
+ });
645
+ it("should use the command given after -- when no registry id was named", async () => {
646
+ const fetchImpl = vi.fn();
647
+ const resolved = await resolveAgentCommand(options(), ["gemini", "--acp"], fetchImpl);
648
+ expect(resolved).toEqual({ command: ["gemini", "--acp"] });
649
+ expect(fetchImpl).not.toHaveBeenCalled();
650
+ });
651
+ it("should resolve a registry id into the command that runs it", async () => {
652
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
653
+ const fetchImpl = vi.fn().mockResolvedValue({
654
+ ok: true,
655
+ json: async () => ({
656
+ version: "1.0.0",
657
+ agents: [
658
+ {
659
+ id: "gemini",
660
+ name: "Gemini CLI",
661
+ version: "0.58.0",
662
+ description: "Google's CLI",
663
+ distribution: {
664
+ npx: { package: "@google/gemini-cli@0.58.0", args: ["--acp"], env: { K: "v" } },
665
+ },
666
+ },
667
+ ],
668
+ }),
669
+ });
670
+ const resolved = await resolveAgentCommand(options({ agentId: "gemini" }), [], fetchImpl);
671
+ errorSpy.mockRestore();
672
+ expect(resolved.command[0]).toMatch(/^npx/);
673
+ expect(resolved.command.slice(1)).toEqual(["-y", "@google/gemini-cli@0.58.0", "--acp"]);
674
+ expect(resolved.env).toEqual({ K: "v" });
675
+ });
676
+ it("should surface a registry id that does not exist", async () => {
677
+ const fetchImpl = vi.fn().mockResolvedValue({
678
+ ok: true,
679
+ json: async () => ({ version: "1.0.0", agents: [] }),
680
+ });
681
+ await expect(resolveAgentCommand(options({ agentId: "nope" }), [], fetchImpl)).rejects.toThrow(/No agent "nope" in the ACP registry/);
682
+ });
683
+ });
684
+ describe("runAgentCommand", () => {
685
+ const agentrqConfig = { env: {} };
686
+ let logSpy;
687
+ let errorSpy;
688
+ function mockConnection(overrides, initResult = {}) {
689
+ const connection = {
690
+ initialize: vi.fn().mockResolvedValue({ protocolVersion: "0.1.0", ...initResult }),
691
+ ...overrides,
692
+ };
693
+ vi.mocked(acp.ClientSideConnection).mockImplementationOnce(function () {
694
+ return connection;
695
+ });
696
+ return connection;
697
+ }
698
+ beforeEach(() => {
699
+ spawnedAgents.length = 0;
700
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
701
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
702
+ });
703
+ it("should list the agent's login methods and shut the agent down again", async () => {
704
+ mockConnection({}, {
705
+ authMethods: [{ id: "agent-login", name: "Agent login" }],
706
+ agentCapabilities: { auth: { logout: {} } },
707
+ });
708
+ await runAgentCommand("list-auth-methods", ["gemini", "--acp"], agentrqConfig, fakeBridge());
709
+ const printed = logSpy.mock.calls.flat().join("\n");
710
+ expect(printed).toContain("Agent login (agent-login)");
711
+ expect(printed).toContain("--logout");
712
+ expect(spawnedAgents[0].kill).toHaveBeenCalled();
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
+ });
732
+ it("should report agents that advertise no login", async () => {
733
+ mockConnection({});
734
+ await runAgentCommand("list-auth-methods", ["gemini", "--acp"], agentrqConfig, fakeBridge());
735
+ expect(logSpy.mock.calls.flat().join("\n")).toContain("no authentication methods");
736
+ });
737
+ it("should log out", async () => {
738
+ const connection = mockConnection({ logout: vi.fn().mockResolvedValue({}) }, {
739
+ agentCapabilities: { auth: { logout: {} } },
740
+ });
741
+ await runAgentCommand("logout", ["gemini", "--acp"], agentrqConfig, fakeBridge());
742
+ expect(connection.logout).toHaveBeenCalledWith({});
743
+ });
744
+ it("should log in with the method the user named", async () => {
745
+ const connection = mockConnection({ authenticate: vi.fn().mockResolvedValue({}) }, {
746
+ authMethods: [
747
+ { id: "agent-login", name: "Agent login" },
748
+ { id: "oauth", name: "OAuth" },
749
+ ],
750
+ });
751
+ await runAgentCommand("login", ["gemini", "--acp"], agentrqConfig, fakeBridge(), "oauth");
752
+ expect(connection.authenticate).toHaveBeenCalledWith({ methodId: "oauth" });
753
+ });
754
+ it("should still shut the agent down when the login fails", async () => {
755
+ mockConnection({ authenticate: vi.fn() }, {
756
+ authMethods: [{ id: "agent-login", name: "Agent login" }],
757
+ });
758
+ await expect(runAgentCommand("login", ["gemini", "--acp"], agentrqConfig, fakeBridge(), "missing")).rejects.toThrow(/Unknown authentication method/);
759
+ expect(spawnedAgents[0].kill).toHaveBeenCalled();
760
+ });
761
+ });
762
+ describe("helpText", () => {
763
+ it("should explain every option the parser accepts", () => {
764
+ const text = helpText("9.9.9");
765
+ // Every documented flag must be one parseGatewayArgs actually handles,
766
+ // and every flag it handles must be documented.
767
+ const documented = [...text.matchAll(/^\s{2}(--[a-z-]+|-h)/gm)].map((m) => m[1]);
768
+ expect(new Set(documented)).toEqual(new Set([
769
+ "--agent",
770
+ "--list-agents",
771
+ "--agent-info",
772
+ "--allow-unverified-agent",
773
+ "--registry-url",
774
+ "--list-auth-methods",
775
+ "--login",
776
+ "--logout",
777
+ "--auth-method",
778
+ "--max-concurrency",
779
+ "--permission-timeout",
780
+ "--help",
781
+ ]));
782
+ });
783
+ it("should name the version and show how to run an agent both ways", () => {
784
+ const text = helpText("9.9.9");
785
+ expect(text).toContain("acp-gateway 9.9.9");
786
+ expect(text).toContain("acp-gateway --agent gemini");
787
+ expect(text).toContain("acp-gateway -- gemini --acp");
788
+ expect(text).toContain(".mcp.json");
789
+ });
790
+ it("should say why an unverified agent is not installed by default", () => {
791
+ expect(helpText()).toMatch(/no way to tell what was downloaded/);
792
+ });
793
+ });
794
+ describe("printHelp", () => {
795
+ it("should print the help text", () => {
796
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
797
+ printHelp();
798
+ const printed = logSpy.mock.calls.flat().join("\n");
799
+ logSpy.mockRestore();
800
+ expect(printed).toContain("USAGE");
801
+ expect(printed).toContain("--list-agents");
802
+ });
803
+ });
804
+ describe("authConfig", () => {
805
+ it("should start out with no preferred login method", () => {
806
+ expect(authConfig).toEqual({});
807
+ });
808
+ });
809
+ describe("isInteractiveTerminal", () => {
810
+ it("should be true only when both stdin and stderr are a TTY", () => {
811
+ const original = { stdin: process.stdin.isTTY, stderr: process.stderr.isTTY };
812
+ try {
813
+ process.stdin.isTTY = true;
814
+ process.stderr.isTTY = true;
815
+ expect(isInteractiveTerminal()).toBe(true);
816
+ process.stderr.isTTY = false;
817
+ expect(isInteractiveTerminal()).toBe(false);
818
+ }
819
+ finally {
820
+ process.stdin.isTTY = original.stdin;
821
+ process.stderr.isTTY = original.stderr;
822
+ }
823
+ });
351
824
  });
352
825
  describe("pickHumanApprovalMode", () => {
353
826
  // The real modes codex-acp advertises. Its default is "agent", whose
@@ -482,5 +955,64 @@ describe("index", () => {
482
955
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to set session mode"), expect.any(Error));
483
956
  });
484
957
  });
958
+ describe("handleAgentModeChange", () => {
959
+ const modes = {
960
+ currentModeId: "ask",
961
+ availableModes: [
962
+ { id: "ask", name: "Ask first" },
963
+ { id: "auto", name: "Auto approve" },
964
+ ],
965
+ };
966
+ let errorSpy;
967
+ beforeEach(() => {
968
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
969
+ });
970
+ afterEach(() => errorSpy.mockRestore());
971
+ it("should leave a mode that still asks the human alone", async () => {
972
+ const connection = { setSessionMode: vi.fn() };
973
+ await handleAgentModeChange(connection, "sess-ok", "ask", modes);
974
+ expect(connection.setSessionMode).not.toHaveBeenCalled();
975
+ });
976
+ it("should put the session back when the agent starts approving for us", async () => {
977
+ const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
978
+ await handleAgentModeChange(connection, "sess-drift", "auto", modes);
979
+ expect(connection.setSessionMode).toHaveBeenCalledWith({
980
+ sessionId: "sess-drift",
981
+ modeId: "ask",
982
+ });
983
+ });
984
+ it("should treat a mode the agent never advertised as untrusted", async () => {
985
+ const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
986
+ await handleAgentModeChange(connection, "sess-unknown", "something-new", modes);
987
+ expect(connection.setSessionMode).toHaveBeenCalledWith({
988
+ sessionId: "sess-unknown",
989
+ modeId: "ask",
990
+ });
991
+ });
992
+ it("should stop fighting an agent that keeps switching back", async () => {
993
+ const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
994
+ for (let i = 0; i < 5; i++) {
995
+ await handleAgentModeChange(connection, "sess-stubborn", "auto", modes);
996
+ }
997
+ // An unbounded fight would be an endless stream of setSessionMode calls.
998
+ expect(connection.setSessionMode).toHaveBeenCalledTimes(3);
999
+ expect(errorSpy.mock.calls.flat().join("\n")).toContain("Giving up after 3 attempts");
1000
+ });
1001
+ it("should start counting again once the session is back in a safe mode", async () => {
1002
+ const connection = { setSessionMode: vi.fn().mockResolvedValue({}) };
1003
+ await handleAgentModeChange(connection, "sess-recovered", "auto", modes);
1004
+ await handleAgentModeChange(connection, "sess-recovered", "ask", modes);
1005
+ for (let i = 0; i < 4; i++) {
1006
+ await handleAgentModeChange(connection, "sess-recovered", "auto", modes);
1007
+ }
1008
+ expect(connection.setSessionMode).toHaveBeenCalledTimes(4);
1009
+ });
1010
+ it("should do nothing for an agent that offers no modes at all", async () => {
1011
+ const connection = { setSessionMode: vi.fn() };
1012
+ await handleAgentModeChange(connection, "sess-modeless", "whatever", undefined);
1013
+ await handleAgentModeChange(connection, "sess-modeless", "whatever", { availableModes: [] });
1014
+ expect(connection.setSessionMode).not.toHaveBeenCalled();
1015
+ });
1016
+ });
485
1017
  });
486
1018
  //# sourceMappingURL=index.test.js.map