diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 6df5f827fa76..0764d5250bcf 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -46,7 +46,7 @@ import * as IdAllocator from "../IdAllocator.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; -import { OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts"; +import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; @@ -2702,6 +2702,86 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () => + Effect.gen(function* () { + // Spelled out, this delegated thread's server name would be 120 characters. + const child = ThreadId.make( + "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Asubproject-b-round1", + ); + const server = "t3-code-aa73fa1e03099934"; + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment:opencode2-adapter"), + threadId: child, + providerSessionId: "mcp:opencode2-adapter", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:3773/mcp", + authorizationHeader: "Bearer thread-credential", + browserToolsAvailable: false, + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(child)), + ); + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ + ...opening, + out("session.get", { sessionID: SESSION }), + // The session already has this thread's rules, so they are not rewritten. + replyData( + "session.get", + sessionInfo({ + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "t3-code-*", resource: "*", effect: "deny" }, + { action: `${server}_*`, resource: "*", effect: "allow" }, + ], + }), + ), + ...noOpenRequests, + out("mcp.add", { + server, + "location[directory]": WORK, + config: { + type: "remote", + url: "http://127.0.0.1:3773/mcp", + headers: { Authorization: "Bearer thread-credential" }, + oauth: false, + }, + }), + reply("mcp.add", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + out("mcp.remove", { server, "location[directory]": WORK }), + reply("mcp.remove", null), + ]); + const thread = yield* runtime.resumeThread({ + providerThread: { ...providerThread(yield* DateTime.now), appThreadId: child }, + threadId: child, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ ...turnInput(thread), threadId: child }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + yield* runtime.unloadThread!({ providerThread: thread }); + }).pipe(Effect.scoped), + ); + + it("names each thread's MCP server within OpenCode's limits, one name per thread", () => { + const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258"; + const ids = [ + `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`, + `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`, + "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1", + "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2", + ]; + const names = ids.map(t3McpServerName); + for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/); + assert.equal(new Set(names).size, ids.length); + assert.deepEqual(ids.map(t3McpServerName), names); + // A name that already fits stays readable. + assert.equal(t3McpServerName(threadId), "t3-code-thread_opencode2-adapter"); + }); + it.effect("reads user and assistant text from the session's message list", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntimeWithInstructions([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index bcc9bcc646bb..8d19156ee8d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -21,6 +21,8 @@ * * @module orchestration-v2/Adapters/OpenCode2AdapterV2 */ +import * as NodeCrypto from "node:crypto"; + import { AbsolutePath, Agent, @@ -447,9 +449,16 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour * T3's MCP server is registered per directory, not per session, so each thread * gets its own `t3-code-` entry with its own credential. OpenCode names * an MCP tool's permission `_` (non-alphanumerics become `_`). + * OpenCode skips every tool of a server whose name is over 64 characters (its + * tool namespace limit), and its router rejects adding one over 100, so a + * thread id that does not fit is replaced by a digest of it. */ -const t3McpServerName = (threadId: string) => - `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; +export const t3McpServerName = (threadId: string) => { + const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`; + if (name.length <= 64) return name; + const digest = NodeCrypto.createHash("sha256").update(threadId).digest("hex"); + return `t3-code-${digest.slice(0, 16)}`; +}; /** * The rules that keep T3's MCP servers to their own thread, after the mode's: