diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 70f45bc8a46c0..db943ff90be4a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1259,7 +1259,7 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("gives Copilot T3's MCP server over HTTP when it advertises HTTP", () => + it.effect("gives HTTP-capable agents T3's MCP server over HTTP", () => Effect.gen(function* () { const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* (yield* Path.Path).fromFileUrl( @@ -1345,6 +1345,14 @@ describe("AcpRegistryAdapterV2", () => { }; return { new: sent("session/new"), load: sent("session/load") }; }); + const plainHttp = (authorization: string) => [ + { + type: "http", + name: "t3-code", + url: endpoint, + headers: [{ name: "Authorization", value: authorization }], + }, + ]; const http = (authorization: string) => [ { type: "http", @@ -1389,6 +1397,30 @@ describe("AcpRegistryAdapterV2", () => { new: stdio("Bearer opened-token"), load: stdio("Bearer rotated-token"), }); + // Command Code cannot start the stdio bridge on Windows: it spawns MCP commands through + // cmd.exe, which splits the unquoted T3 executable path at the space. + const commandCode = { + source: "local", + commandPath: "C:\\tools\\cmdc.exe", + commandArgs: ["acp"], + }; + // Command Code is not Copilot: no deferred tool-exposure header. + assert.deepEqual(yield* sessionMcpServers(commandCode, { T3_ACP_MCP_HTTP: "1" }), { + new: plainHttp("Bearer opened-token"), + load: plainHttp("Bearer rotated-token"), + }); + assert.deepEqual(yield* sessionMcpServers(commandCode), { + new: stdio("Bearer opened-token"), + load: stdio("Bearer rotated-token"), + }); + // Any other local agent keeps the stdio bridge. + assert.deepEqual( + yield* sessionMcpServers( + { source: "local", commandPath: "C:\\tools\\node.exe", commandArgs: ["agent.mjs"] }, + { T3_ACP_MCP_HTTP: "1" }, + ), + { new: stdio("Bearer opened-token"), load: stdio("Bearer rotated-token") }, + ); }).pipe(Effect.provide(layerTest)), ); diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index d9d9d883c2c57..ba9deefaf93cc 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -23,6 +23,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import { prefersHttpMcpTransport } from "./mcpTransport.ts"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; const registryUrl = "https://registry.test/registry.json"; @@ -336,6 +337,54 @@ describe("AcpRegistrySupport", () => { ), ); + it.effect("resolves the local Command Code configurations the HTTP selector matches", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-command-code-", + }); + const catalog = (executable: string) => + AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => executable), + ); + + const bare = decodeAcpRegistrySettings({ source: "local", commandPath: "cmdc" }); + expect(prefersHttpMcpTransport(bare)).toBe(true); + expect( + yield* (yield* catalog("C:\\bin\\cmdc.exe")).resolve(bare, "/workspace"), + ).toMatchObject({ spawn: { command: "C:\\bin\\cmdc.exe", shell: false } }); + // A bare name that PATHEXT resolves to a batch shim never launches, so the transport it + // selects cannot matter: there is no session to attach MCP servers to. + expect( + yield* (yield* catalog("C:\\bin\\cmdc.cmd")).resolve(bare, "/workspace").pipe(Effect.flip), + ).toMatchObject({ reason: "runner_unavailable" }); + + const vendored = decodeAcpRegistrySettings({ + source: "local", + commandPath: "node.exe", + commandArgs: [ + "C:\\Users\\me\\.t3\\bin\\cmdcode\\node_modules\\command-code\\dist\\index.mjs", + "acp", + ], + }); + expect(prefersHttpMcpTransport(vendored)).toBe(true); + expect( + yield* (yield* catalog("C:\\Program Files\\nodejs\\node.exe")).resolve( + vendored, + "/workspace", + ), + ).toMatchObject({ spawn: { command: "C:\\Program Files\\nodejs\\node.exe" } }); + }).pipe( + Effect.scoped, + Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))), + ), + ); + it.effect("reports missing or non-executable local commands without registry fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 25656a9d808e1..31ef391d19c34 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -17,6 +17,7 @@ import { isCopilotLostSessionError, makeCopilotSubagentRouting, } from "./copilotAcp.ts"; +import { prefersHttpMcpTransport } from "./mcpTransport.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; @@ -221,6 +222,11 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu } : options; const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined; + // Copilot CLI 1.0.105 rejects stdio MCP servers from the client, and Command Code cannot + // start the stdio bridge on Windows (it spawns MCP commands through cmd.exe, which splits + // the unquoted "T3 Code (Beta).exe" path). Both take T3's MCP endpoint over ACP HTTP. + const useHttpMcpServers = + copilotRouting !== undefined || prefersHttpMcpTransport(options.settings); const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, // Cancelling a Copilot prompt also kills the background agents it started. @@ -268,8 +274,6 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ? {} : { clientCapabilitiesMeta: copilotClientCapabilitiesMeta, - // Copilot CLI 1.0.105 rejects stdio MCP servers from the client. - mcpOverHttp: true, deferMcpTools: true, registerExtensions: copilotRouting.registerExtensions, normalizeSessionUpdate: copilotRouting.normalizeSessionUpdate, @@ -278,6 +282,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu // session/new reports a built-in default, not the model Copilot runs (github/copilot-cli#5363). alwaysSetModel: true, }), + mcpOverHttp: useHttpMcpServers, makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(runtimeOptions, catalog), ...(runtimeCoordinator === undefined ? {} diff --git a/packages/provider-acp-registry/src/server/mcpTransport.test.ts b/packages/provider-acp-registry/src/server/mcpTransport.test.ts new file mode 100644 index 0000000000000..3de01b35ba9d0 --- /dev/null +++ b/packages/provider-acp-registry/src/server/mcpTransport.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { AcpRegistrySettings } from "../settings.ts"; +import { prefersHttpMcpTransport } from "./mcpTransport.ts"; + +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); + +const local = (commandPath: string, commandArgs: string[] = []) => + decodeSettings({ source: "local", commandPath, commandArgs }); + +describe("prefersHttpMcpTransport", () => { + it("uses HTTP for the vendored Command Code launcher", () => { + expect( + prefersHttpMcpTransport( + local("C:\\Users\\me\\.t3\\bin\\cmdcode\\node.exe", [ + "C:\\Users\\me\\.t3\\bin\\cmdcode\\node_modules\\command-code\\dist\\index.mjs", + "acp", + ]), + ), + ).toBe(true); + expect( + prefersHttpMcpTransport( + local("C:\\tools\\node.exe", ["C:\\src\\command-code\\dist\\cli.mjs", "acp"]), + ), + ).toBe(true); + }); + + it("uses HTTP for the global Command Code executable", () => { + expect(prefersHttpMcpTransport(local("C:\\tools\\cmdc.exe", ["acp"]))).toBe(true); + expect(prefersHttpMcpTransport(local("/usr/local/bin/cmdc"))).toBe(true); + }); + + it("keeps the stdio bridge for a batch shim T3 refuses to launch", () => { + expect(prefersHttpMcpTransport(local("C:\\tools\\cmdc.cmd", ["acp"]))).toBe(false); + }); + + it("keeps the stdio bridge for other local agents", () => { + expect( + prefersHttpMcpTransport( + local("C:\\tools\\node.exe", ["C:\\tools\\some-other-agent\\dist\\index.mjs", "acp"]), + ), + ).toBe(false); + expect(prefersHttpMcpTransport(local("C:\\tools\\claude-agent-acp.exe"))).toBe(false); + expect(prefersHttpMcpTransport(local("C:\\tools\\command-code\\runner.mjs"))).toBe(false); + }); + + it("keeps the stdio bridge for registry agents", () => { + expect( + prefersHttpMcpTransport(decodeSettings({ source: "registry", agentId: "claude-acp" })), + ).toBe(false); + }); +}); diff --git a/packages/provider-acp-registry/src/server/mcpTransport.ts b/packages/provider-acp-registry/src/server/mcpTransport.ts new file mode 100644 index 0000000000000..49881663a9111 --- /dev/null +++ b/packages/provider-acp-registry/src/server/mcpTransport.ts @@ -0,0 +1,27 @@ +import type { AcpRegistrySettings } from "../settings.ts"; + +// AcpRegistrySupport.validateLocalExecutable refuses .cmd/.bat on Windows, so only native +// executables are launchable. +const COMMAND_CODE_EXECUTABLES = /^(?:cmdc|command-code|commandcode)(?:\.exe)?$/iu; +const COMMAND_CODE_ENTRY_POINTS = [ + /(?:^|[\\/])node_modules[\\/]command-code[\\/]/iu, + /(?:^|[\\/])command-code[\\/]dist[\\/](?:index|cli)\.mjs$/iu, +]; + +/** + * Agents that take T3's MCP endpoint over ACP HTTP: Command Code cannot start the stdio + * bridge on Windows, where it spawns MCP commands through cmd.exe and the unquoted T3 + * executable path splits at the space. + */ +export function prefersHttpMcpTransport(settings: AcpRegistrySettings): boolean { + if (settings.source !== "local") { + return false; + } + const executable = settings.commandPath.split(/[\\/]/u).pop() ?? ""; + return ( + COMMAND_CODE_EXECUTABLES.test(executable) || + settings.commandArgs.some((argument) => + COMMAND_CODE_ENTRY_POINTS.some((pattern) => pattern.test(argument)), + ) + ); +}