@agentrq/acp-gateway 0.2.8 → 0.2.10

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,7 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { EventEmitter } from "node:events";
3
6
  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, findActiveSession, handleTaskCancellation, cancelledTaskSeq, markTaskCancelled, isTaskCancelled, nextTaskSeq, supportsCloseSession, closeSession, closeAllSessions, setupSignalHandlers, } from "../index.js";
7
+ import { createAcpSessionSwitcher, checkForNextTask, mapMcpServers, TaskQueue, getOrCreateSession, activeSessions, authConfig, modelConfig, createSessionWithAuth, isInteractiveTerminal, openAgentConnection, parseGatewayArgs, assertAgentRunnable, helpText, isRunnable, resolveOnPath, spawnTarget, needsShell, escapeCmdCommand, quoteForCmd, spawnArgsFor, terminateAgentProcess, printHelp, resolveAgentCommand, runListAgents, pickHumanApprovalMode, enforceHumanApprovalMode, handleAgentModeChange, runAgentCommand, findActiveSession, handleTaskCancellation, cancelledTaskSeq, markTaskCancelled, isTaskCancelled, nextTaskSeq, supportsCloseSession, closeSession, closeAllSessions, setupSignalHandlers, } from "../index.js";
5
8
  import { AUTH_REQUIRED_CODE } from "../auth.js";
6
9
  /**
7
10
  * A stand-in for the workspace bridge.
@@ -10,7 +13,10 @@ import { AUTH_REQUIRED_CODE } from "../auth.js";
10
13
  * a bare object is not enough — the real bridge is an EventEmitter.
11
14
  */
12
15
  function fakeBridge() {
13
- return new EventEmitter();
16
+ const emitter = new EventEmitter();
17
+ emitter.sendNotification = vi.fn().mockResolvedValue(undefined);
18
+ emitter.callTool = vi.fn().mockResolvedValue({ isError: false, content: [] });
19
+ return emitter;
14
20
  }
15
21
  // Real emitters so the agent-process lifecycle handlers can be exercised.
16
22
  const { spawnedAgents } = vi.hoisted(() => ({ spawnedAgents: [] }));
@@ -457,6 +463,58 @@ describe("index", () => {
457
463
  expect(newSession).toHaveBeenCalledTimes(2);
458
464
  expect(session.sessionId).toBe("authed-sess");
459
465
  });
466
+ it("should forward models to workspace on session creation and set model if configured", async () => {
467
+ const mockBridge = fakeBridge();
468
+ const setSessionConfigOption = vi.fn().mockResolvedValue({
469
+ configOptions: [
470
+ {
471
+ id: "model",
472
+ name: "Model",
473
+ type: "select",
474
+ currentValue: "gpt-4o",
475
+ options: [
476
+ { value: "gpt-4", name: "GPT-4" },
477
+ { value: "gpt-4o", name: "GPT-4o" },
478
+ ],
479
+ },
480
+ ],
481
+ });
482
+ vi.mocked(acp.ClientSideConnection).mockImplementationOnce(function () {
483
+ return {
484
+ initialize: vi.fn().mockResolvedValue({ protocolVersion: "0.1.0" }),
485
+ newSession: vi.fn().mockResolvedValue({
486
+ sessionId: "model-sess",
487
+ configOptions: [
488
+ {
489
+ id: "model",
490
+ name: "Model",
491
+ type: "select",
492
+ currentValue: "gpt-4",
493
+ options: [
494
+ { value: "gpt-4", name: "GPT-4" },
495
+ { value: "gpt-4o", name: "GPT-4o" },
496
+ ],
497
+ },
498
+ ],
499
+ }),
500
+ setSessionConfigOption,
501
+ prompt: vi.fn(),
502
+ };
503
+ });
504
+ modelConfig.modelId = "gpt-4o";
505
+ const session = await getOrCreateSession("T-Model", ["node", "agent.js"], [], { env: {} }, mockBridge);
506
+ expect(setSessionConfigOption).toHaveBeenCalledWith({
507
+ sessionId: "model-sess",
508
+ configId: "model",
509
+ value: "gpt-4o",
510
+ });
511
+ expect(mockBridge.sendNotification).toHaveBeenCalledWith("notifications/claude/channel/models", expect.objectContaining({
512
+ task_id: "T-Model",
513
+ session_id: "model-sess",
514
+ current_model: "gpt-4o",
515
+ }));
516
+ modelConfig.modelId = undefined;
517
+ });
460
518
  it("should drop the cached session when the agent process dies", async () => {
461
519
  const mockBridge = fakeBridge();
462
520
  await getOrCreateSession("T-Exit", ["node", "agent.js"], [], { env: {} }, mockBridge);
@@ -493,6 +551,38 @@ describe("index", () => {
493
551
  }),
494
552
  }));
495
553
  });
554
+ it("spawns an ordinary agent command directly", async () => {
555
+ const { spawn } = await import("node:child_process");
556
+ await openAgentConnection({
557
+ acpCmdArgs: ["node", "agent.js"],
558
+ mcpBridge: fakeBridge(),
559
+ label: "spawn-plain",
560
+ });
561
+ expect(vi.mocked(spawn)).toHaveBeenLastCalledWith("node", ["agent.js"], expect.objectContaining({ shell: false }));
562
+ });
563
+ it("resolves npx on Windows and spawns the batch file through a shell", async () => {
564
+ // The whole reported failure in one test: the registry says "npx", the
565
+ // machine has it as npx.cmd, and a batch file needs a shell to start.
566
+ const { spawn } = await import("node:child_process");
567
+ const dir = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
568
+ writeFileSync(join(dir, "npx.cmd"), "");
569
+ const platform = process.platform;
570
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
571
+ vi.stubEnv("PATH", dir);
572
+ vi.stubEnv("PATHEXT", ".EXE;.cmd");
573
+ try {
574
+ await openAgentConnection({
575
+ acpCmdArgs: ["npx", "-y", "@zed-industries/codex-acp"],
576
+ mcpBridge: fakeBridge(),
577
+ label: "spawn-windows",
578
+ });
579
+ }
580
+ finally {
581
+ Object.defineProperty(process, "platform", { value: platform, configurable: true });
582
+ vi.unstubAllEnvs();
583
+ }
584
+ expect(vi.mocked(spawn)).toHaveBeenLastCalledWith(join(dir, "npx.cmd"), ['^"-y^"', '^"@zed-industries/codex-acp^"'], expect.objectContaining({ shell: true }));
585
+ });
496
586
  it("should report the agent's login methods and survive process failures", async () => {
497
587
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
498
588
  vi.mocked(acp.ClientSideConnection).mockImplementationOnce(function () {
@@ -613,6 +703,14 @@ describe("index", () => {
613
703
  expect(parseGatewayArgs(["--registry-url", "http://localhost/registry.json"]).registryUrl).toBe("http://localhost/registry.json");
614
704
  expect(parseGatewayArgs(["--registry-url"]).registryUrl).toBeUndefined();
615
705
  });
706
+ it("should recognise model options", () => {
707
+ expect(parseGatewayArgs(["--list-models"])).toMatchObject({ command: "list-models" });
708
+ expect(parseGatewayArgs(["--model", "claude-3-7-sonnet"])).toMatchObject({
709
+ command: "run",
710
+ modelId: "claude-3-7-sonnet",
711
+ });
712
+ expect(parseGatewayArgs(["--model"]).modelId).toBeUndefined();
713
+ });
616
714
  it("should recognise both spellings of the help flag", () => {
617
715
  expect(parseGatewayArgs(["--help"]).command).toBe("help");
618
716
  expect(parseGatewayArgs(["-h"]).command).toBe("help");
@@ -637,6 +735,160 @@ describe("index", () => {
637
735
  expect(isRunnable("npx", { PATH: "C:\\tools", PATHEXT: ".EXE" }, "win32")).toBe(false);
638
736
  expect(isRunnable("C:\\nope\\agent.exe", {}, "win32")).toBe(false);
639
737
  });
738
+ it("finds a Windows command that already carries its suffix", () => {
739
+ // The registry launches npm agents as "npx.cmd"; appending PATHEXT to
740
+ // that would only ever look for "npx.cmd.EXE" and friends.
741
+ const dir = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
742
+ writeFileSync(join(dir, "npx.cmd"), "");
743
+ expect(isRunnable("npx.cmd", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, "win32")).toBe(true);
744
+ expect(isRunnable("npx.cmd", { PATH: dir }, "win32")).toBe(true);
745
+ // A suffix PATHEXT does not list is still a bare name to complete.
746
+ expect(isRunnable("npx.cmd", { PATH: dir, PATHEXT: ".EXE" }, "win32")).toBe(false);
747
+ });
748
+ it("still completes a bare Windows name from PATHEXT", () => {
749
+ const dir = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
750
+ writeFileSync(join(dir, "agent.CMD"), "");
751
+ expect(isRunnable("agent", { PATH: dir, PATHEXT: ".EXE;.CMD" }, "win32")).toBe(true);
752
+ expect(isRunnable("agent", { PATH: dir, PATHEXT: ".EXE" }, "win32")).toBe(false);
753
+ });
754
+ });
755
+ describe("resolveOnPath", () => {
756
+ it("returns the file a name resolves to", () => {
757
+ const dir = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
758
+ writeFileSync(join(dir, "npx.cmd"), "");
759
+ expect(resolveOnPath("npx", { PATH: dir, PATHEXT: ".EXE;.cmd" }, "win32")).toBe(join(dir, "npx.cmd"));
760
+ expect(resolveOnPath("npx", { PATH: dir, PATHEXT: ".EXE" }, "win32")).toBeUndefined();
761
+ });
762
+ it("takes the first directory on PATH that has it", () => {
763
+ const first = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
764
+ const second = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
765
+ writeFileSync(join(first, "agent.exe"), "");
766
+ writeFileSync(join(second, "agent.exe"), "");
767
+ expect(resolveOnPath("agent", { PATH: `${first};${second}`, PATHEXT: ".exe" }, "win32")).toBe(join(first, "agent.exe"));
768
+ });
769
+ });
770
+ describe("spawnTarget", () => {
771
+ it("leaves a command alone off Windows", () => {
772
+ expect(spawnTarget("npx", { PATH: "/usr/bin" }, "darwin")).toBe("npx");
773
+ });
774
+ it("resolves the suffix a Windows runner is actually installed under", () => {
775
+ // A stock npm install ships npx.cmd, a Volta install ships npx.exe, and
776
+ // only one of those needs a shell to start.
777
+ const batch = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
778
+ writeFileSync(join(batch, "npx.cmd"), "");
779
+ expect(spawnTarget("npx", { PATH: batch, PATHEXT: ".exe;.cmd" }, "win32")).toBe(join(batch, "npx.cmd"));
780
+ const shimmed = mkdtempSync(join(tmpdir(), "acp-gateway-path-"));
781
+ writeFileSync(join(shimmed, "npx.exe"), "");
782
+ const resolved = spawnTarget("npx", { PATH: shimmed, PATHEXT: ".exe;.cmd" }, "win32");
783
+ expect(resolved).toBe(join(shimmed, "npx.exe"));
784
+ expect(needsShell(resolved, "win32")).toBe(false);
785
+ });
786
+ it("falls back to the name when nothing on PATH matches", () => {
787
+ expect(spawnTarget("npx", { PATH: "C:\\nowhere", PATHEXT: ".EXE" }, "win32")).toBe("npx");
788
+ expect(spawnTarget("C:\\tools\\agent.cmd", { PATH: "" }, "win32")).toBe("C:\\tools\\agent.cmd");
789
+ });
790
+ });
791
+ describe("needsShell", () => {
792
+ it("asks for a shell only for Windows batch files", () => {
793
+ expect(needsShell("npx.cmd", "win32")).toBe(true);
794
+ expect(needsShell("C:\\tools\\Agent.BAT", "win32")).toBe(true);
795
+ expect(needsShell("node.exe", "win32")).toBe(false);
796
+ expect(needsShell("npx", "win32")).toBe(false);
797
+ });
798
+ it("never asks for one off Windows", () => {
799
+ expect(needsShell("npx.cmd", "darwin")).toBe(false);
800
+ expect(needsShell("npx", "linux")).toBe(false);
801
+ });
802
+ });
803
+ describe("escapeCmdCommand", () => {
804
+ it("leaves a plain program name alone", () => {
805
+ expect(escapeCmdCommand("npx.cmd")).toBe("npx.cmd");
806
+ });
807
+ it("escapes what cmd.exe would otherwise act on", () => {
808
+ // The space is escaped rather than quoted, so cmd.exe reads the whole
809
+ // path as the program name.
810
+ expect(escapeCmdCommand("C:\\Program Files\\nodejs\\npx.cmd")).toBe("C:\\Program^ Files\\nodejs\\npx.cmd");
811
+ expect(escapeCmdCommand("a&b.cmd")).toBe("a^&b.cmd");
812
+ });
813
+ });
814
+ describe("quoteForCmd", () => {
815
+ it("quotes every argument for both parsers", () => {
816
+ // Quoted for the agent's own argument parser, then escaped so cmd.exe
817
+ // passes those quotes along instead of eating them.
818
+ expect(quoteForCmd("-y")).toBe('^"-y^"');
819
+ expect(quoteForCmd("@zed-industries/codex-acp")).toBe('^"@zed-industries/codex-acp^"');
820
+ expect(quoteForCmd("")).toBe('^"^"');
821
+ });
822
+ it("neutralises whitespace and shell syntax", () => {
823
+ expect(quoteForCmd("two words")).toBe('^"two^ words^"');
824
+ expect(quoteForCmd("a&b|c")).toBe('^"a^&b^|c^"');
825
+ expect(quoteForCmd("%PATH%")).toBe('^"^%PATH^%^"');
826
+ });
827
+ it("escapes twice for a wrapper that re-enters cmd.exe", () => {
828
+ expect(quoteForCmd("a&b", true)).toBe('^^^"a^^^&b^^^"');
829
+ });
830
+ it("doubles the backslashes a quote would otherwise escape", () => {
831
+ expect(quoteForCmd('say "hi"')).toBe('^"say^ \\^"hi\\^"^"');
832
+ expect(quoteForCmd("C:\\dir\\")).toBe('^"C:\\dir\\\\^"');
833
+ // A run of backslashes before a quote is doubled whole, then the quote
834
+ // gets its own: two backslashes and a quote need five and a quote.
835
+ expect(quoteForCmd('a\\\\"b')).toBe('^"a\\\\\\\\\\^"b^"');
836
+ });
837
+ });
838
+ describe("spawnArgsFor", () => {
839
+ it("passes the command through when no shell is involved", () => {
840
+ expect(spawnArgsFor("npx", ["-y", "some pkg"], "darwin")).toEqual([
841
+ "npx",
842
+ ["-y", "some pkg"],
843
+ ]);
844
+ expect(spawnArgsFor("npx.cmd", ["-y", "codex-acp"], "linux")).toEqual([
845
+ "npx.cmd",
846
+ ["-y", "codex-acp"],
847
+ ]);
848
+ });
849
+ it("escapes twice for an npm bin shim, which re-enters cmd.exe", () => {
850
+ expect(spawnArgsFor("C:\\proj\\node_modules\\.bin\\my-agent.cmd", ["--flag"], "win32")).toEqual(["C:\\proj\\node_modules\\.bin\\my-agent.cmd", ['^^^"--flag^^^"']]);
851
+ });
852
+ it("escapes the command line a Windows shell would re-parse", () => {
853
+ expect(spawnArgsFor("C:\\Program Files\\nodejs\\npx.cmd", ["-y", "codex-acp"], "win32")).toEqual([
854
+ "C:\\Program^ Files\\nodejs\\npx.cmd",
855
+ ['^"-y^"', '^"codex-acp^"'],
856
+ ]);
857
+ });
858
+ });
859
+ describe("terminateAgentProcess", () => {
860
+ it("just kills the process off Windows", () => {
861
+ const child = { pid: 42, kill: vi.fn() };
862
+ const spawnImpl = vi.fn();
863
+ terminateAgentProcess(child, "darwin", spawnImpl);
864
+ expect(child.kill).toHaveBeenCalled();
865
+ expect(spawnImpl).not.toHaveBeenCalled();
866
+ });
867
+ it("kills the whole tree on Windows", () => {
868
+ const killer = new EventEmitter();
869
+ killer.unref = vi.fn();
870
+ const spawnImpl = vi.fn(() => killer);
871
+ const child = { pid: 4242, kill: vi.fn() };
872
+ terminateAgentProcess(child, "win32", spawnImpl);
873
+ expect(spawnImpl).toHaveBeenCalledWith("taskkill", ["/pid", "4242", "/T", "/F"], { stdio: "ignore" });
874
+ expect(killer.unref).toHaveBeenCalled();
875
+ expect(child.kill).not.toHaveBeenCalled();
876
+ // taskkill itself failing must not leave the agent running.
877
+ killer.emit("error", new Error("not found"));
878
+ expect(child.kill).toHaveBeenCalled();
879
+ });
880
+ it("falls back to a plain kill when the process has no pid", () => {
881
+ const child = { kill: vi.fn() };
882
+ const spawnImpl = vi.fn();
883
+ terminateAgentProcess(child, "win32", spawnImpl);
884
+ expect(child.kill).toHaveBeenCalled();
885
+ expect(spawnImpl).not.toHaveBeenCalled();
886
+ });
887
+ it("does nothing when there is no process to end", () => {
888
+ const spawnImpl = vi.fn();
889
+ expect(() => terminateAgentProcess(undefined, "win32", spawnImpl)).not.toThrow();
890
+ expect(spawnImpl).not.toHaveBeenCalled();
891
+ });
640
892
  });
641
893
  describe("assertAgentRunnable", () => {
642
894
  it("accepts a command that exists", () => {
@@ -758,6 +1010,60 @@ describe("index", () => {
758
1010
  expect(printed).toContain("--logout");
759
1011
  expect(spawnedAgents[0].kill).toHaveBeenCalled();
760
1012
  });
1013
+ it("should list models from configOptions and cleanly close session", async () => {
1014
+ mockConnection({
1015
+ newSession: vi.fn().mockResolvedValue({
1016
+ sessionId: "m-sess",
1017
+ configOptions: [
1018
+ {
1019
+ id: "model",
1020
+ name: "Model",
1021
+ type: "select",
1022
+ currentValue: "claude-3-7-sonnet",
1023
+ options: [
1024
+ { value: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet" },
1025
+ { value: "claude-3-5-haiku", name: "Claude 3.5 Haiku" },
1026
+ ],
1027
+ },
1028
+ ],
1029
+ }),
1030
+ });
1031
+ await runAgentCommand("list-models", ["gemini", "--acp"], agentrqConfig, fakeBridge());
1032
+ const printed = logSpy.mock.calls.flat().join("\n");
1033
+ expect(printed).toContain("Models supported by \"gemini --acp\":");
1034
+ expect(printed).toContain("claude-3-7-sonnet");
1035
+ expect(printed).toContain("Claude 3.7 Sonnet");
1036
+ expect(spawnedAgents[0].kill).toHaveBeenCalled();
1037
+ });
1038
+ it("should list models from unstable_listProviders fallback", async () => {
1039
+ mockConnection({
1040
+ newSession: vi.fn().mockResolvedValue({ sessionId: "m-sess", configOptions: [] }),
1041
+ unstable_listProviders: vi.fn().mockResolvedValue({
1042
+ providers: [
1043
+ {
1044
+ providerId: "anthropic",
1045
+ supported: ["claude-sonnet"],
1046
+ },
1047
+ ],
1048
+ }),
1049
+ }, {
1050
+ agentCapabilities: { providers: true },
1051
+ });
1052
+ await runAgentCommand("list-models", ["gemini", "--acp"], agentrqConfig, fakeBridge());
1053
+ const printed = logSpy.mock.calls.flat().join("\n");
1054
+ expect(printed).toContain("Configurable providers for \"gemini --acp\":");
1055
+ expect(printed).toContain("claude-sonnet");
1056
+ expect(spawnedAgents[0].kill).toHaveBeenCalled();
1057
+ });
1058
+ it("should report when agent advertises no models", async () => {
1059
+ mockConnection({
1060
+ newSession: vi.fn().mockResolvedValue({ sessionId: "m-sess", configOptions: [] }),
1061
+ });
1062
+ await runAgentCommand("list-models", ["gemini", "--acp"], agentrqConfig, fakeBridge());
1063
+ const printed = logSpy.mock.calls.flat().join("\n");
1064
+ expect(printed).toContain("No configurable models advertised by \"gemini --acp\".");
1065
+ expect(spawnedAgents[0].kill).toHaveBeenCalled();
1066
+ });
761
1067
  it("should print what the agent says it supports", async () => {
762
1068
  mockConnection({}, {
763
1069
  protocolVersion: 1,
@@ -818,6 +1124,8 @@ describe("index", () => {
818
1124
  "--agent-info",
819
1125
  "--allow-unverified-agent",
820
1126
  "--registry-url",
1127
+ "--list-models",
1128
+ "--model",
821
1129
  "--list-auth-methods",
822
1130
  "--login",
823
1131
  "--logout",