From 144bc1de04c177d5f88b8efeb1a1860234e9fd3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:32:12 +0000 Subject: [PATCH] fix(opencode): OpenCode 2 threads see T3 Code's MCP tools on their first turn OpenCode before 2.2.6 loads a newly connected MCP server's tools into its tool registry 100 ms after the connection, and its pre-request flush does not wait for that reload. T3 adds the per-thread MCP server right before the first prompt, so that prompt was built without T3's tools. After an add that newly registers the server, wait past that reload on those versions before prompting. Fixes pingdotgg/t3code#22945 AI-assisted; verified with the tests and tools listed above. --- .../Adapters/OpenCode2AdapterV2.test.ts | 51 ++++++++++++------- .../src/server/v2/adapter.ts | 6 +++ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 4f2a838152d7c..1efbbfad09d81 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -4150,26 +4150,37 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { promptAccepted, event("session.execution.succeeded", { sessionID: SESSION }), ]; + const prompts: Array = []; // Idle Location eviction drops OpenCode's runtime MCP servers without a reconnect. - const { runtime, thread } = yield* resumed([ - add, - reply("mcp.add", null), - ...turn, - add, - reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }), - out("session.instructions.entry.put", { - sessionID: SESSION, - key: "t3-code", - value: buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }), - }), - reply("session.instructions.entry.put", null), - ...turn, - out("mcp.remove", { server, "location[directory]": WORK }), - reply("mcp.remove", null), - ]); + const { runtime, thread } = yield* resumed( + [ + add, + reply("mcp.add", null), + ...turn, + add, + reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }), + out("session.instructions.entry.put", { + sessionID: SESSION, + key: "t3-code", + value: buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }), + }), + reply("session.instructions.entry.put", null), + ...turn, + out("mcp.remove", { server, "location[directory]": WORK }), + reply("mcp.remove", null), + ], + { onPrompt: (input) => prompts.push(input) }, + ); for (let index = 0; index < 2; index++) { const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); + const started = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.forkScoped); + // OpenCode before 2.2.6 loads a newly added server's tools after a debounce. + if (index === 0) { + yield* TestClock.adjust("249 millis"); + assert.lengthOf(prompts, 0, "prompted before OpenCode loaded the new MCP server's tools"); + yield* TestClock.adjust("1 millis"); + } + yield* Fiber.join(started); assert.equal((yield* Fiber.join(terminal))?.status, "completed"); } }).pipe(Effect.scoped), @@ -4230,7 +4241,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { runtimePolicy: policy(), }); const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn({ ...turnInput(thread), threadId: child }); + const started = yield* runtime + .startTurn({ ...turnInput(thread), threadId: child }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("250 millis"); + yield* Fiber.join(started); assert.equal((yield* Fiber.join(terminal))?.status, "completed"); yield* runtime.unloadThread!({ providerThread: thread }); }).pipe(Effect.scoped), diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 33d54172335f8..b13f6c361bacf 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -592,6 +592,7 @@ const RECONNECT_WAIT = "30 seconds"; const LOST_BACKGROUND = "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; const INVENTORY_TIMEOUT = "5 seconds"; +const MCP_TOOL_RELOAD_SETTLE = "250 millis"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; // Other failures may still have admitted the prompt. const CLEAR_PROMPT_REJECTIONS: ReadonlySet = new Set([ @@ -4060,6 +4061,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // Re-adding an unchanged config keeps its existing connection. let mcpAvailable = false; if (wanted !== undefined) { + const fresh = state.mcp === undefined; mcpAvailable = yield* client.mcp .add({ server: name, @@ -4081,6 +4083,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ); if (mcpAvailable) state.mcp = wanted; + // Before 2.2.6, OpenCode loads a new server's tools 100 ms after it connects, and a prompt sent sooner lacks them. + if (mcpAvailable && fresh && compareSemverVersions(connection.version, "2.2.6") < 0) { + yield* Effect.sleep(MCP_TOOL_RELOAD_SETTLE); + } } const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }),