From 2f7f7941cd75a79b8a2aa381c7a45eb719a2feeb Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 7 Oct 2026 16:10:52 +0530 Subject: [PATCH] fix(server): OpenCode 2 threads keep T3's tools after an idle project is evicted OpenCode 2 keeps MCP servers added at runtime in the project Location and drops them when it evicts a Location after 60 idle minutes. The adapter added T3's MCP server once and cached that in `state.mcp`, so later turns ran without T3's tools while the instructions still said they were there. Add the server on every turn (OpenCode leaves an unchanged config connected) and tell the model the tools are available only when this turn's add worked. Fixes #16768 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/OpenCode2AdapterV2.test.ts | 52 +++++++++++++++++++ .../Adapters/OpenCode2AdapterV2.ts | 15 +++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 7274de8c386f..f8dda849e1ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2722,6 +2722,58 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("adds T3's MCP server again on every turn, in case OpenCode dropped it", () => + Effect.gen(function* () { + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment:opencode2-adapter"), + threadId, + 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(threadId)), + ); + const server = "t3-code-thread_opencode2-adapter"; + const add = [ + 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), + ]; + const turn = [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]; + // OpenCode keeps runtime-added servers with the Location and forgets them + // when it evicts an idle one, without a reconnect T3 could notice. + const { runtime, thread } = yield* resumed([ + ...add, + ...turn, + ...add, + ...turn, + // Removed when the session closes. + out("mcp.remove", { server, "location[directory]": WORK }), + reply("mcp.remove", null), + ]); + for (let index = 0; index < 2; index++) { + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + } + }).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. diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 102eb721bb1a..14d99d0fbcab 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -396,7 +396,7 @@ interface ThreadState { * inbox, where the next prompt would deliver them first, so it cancels them. */ readonly strandedSteers: Set; - /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ + /** T3's MCP server as last registered for this thread, and the instructions entry sent with it. */ mcp: | { readonly name: string; readonly directory: string; readonly credential: string } | undefined; @@ -3260,9 +3260,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* removeMcp(state.mcp); state.mcp = undefined; } - // T3's tools are an addition: a server that cannot add them still runs the turn. - if (wanted !== undefined && state.mcp === undefined) { - const added = yield* client.mcp + // Added on every turn: OpenCode drops runtime-added servers when it evicts + // an idle Location, and leaves an unchanged one connected. T3's tools are + // an addition: a server that cannot add them still runs the turn. + let mcpAvailable = false; + if (wanted !== undefined) { + mcpAvailable = yield* client.mcp .add({ server: name, location: { directory }, @@ -3282,11 +3285,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ), ); - if (added) state.mcp = wanted; + if (mcpAvailable) state.mcp = wanted; } const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), - t3OrchestrationSystemPrompt(state.mcp !== undefined), + t3OrchestrationSystemPrompt(mcpAvailable), ] .filter((part) => part !== undefined && part.length > 0) .join("\n\n");