From ff75b108fc9d320a3fdf47a002e7b826d5ce63e1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 13:33:08 -0500 Subject: [PATCH 1/3] fix(opencode): a steer during a foreground subagent gets an answer OpenCode holds a steer until the parent's next step, and a foreground subagent call blocks that step until the child finishes. Steering now asks OpenCode to move the session's blocking work to the background, so the subagent keeps running as background work and the parent answers the steer. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 104 ++++++++++++++++++ .../Adapters/OpenCode2AdapterV2.testkit.ts | 1 + .../src/server/v2/adapter.ts | 9 ++ 3 files changed, 114 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index ca0288ab2c5e..c1d755f7db89 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1105,6 +1105,110 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + // regression: #22331 a steer sent while a foreground subagent ran was never answered + it.effect("moves a foreground subagent to the background so the parent answers a steer", () => + Effect.gen(function* () { + const call = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-fg" }; + const steerId = "msg_recorded_steer"; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...call, name: "subagent" }), + event("session.tool.called", { + ...call, + name: "subagent", + input: { description: "Sleep", prompt: "sleep" }, + executed: false, + }), + event("session.created", childCreated(CHILD)), + event("session.tool.progress", { + ...call, + metadata: { sessionID: CHILD, status: "running" }, + }), + event("session.execution.started", { sessionID: CHILD }), + out("session.prompt", { + sessionID: SESSION, + id: steerId, + text: "", + delivery: "steer", + }), + replyData("session.prompt", { + id: steerId, + sessionID: SESSION, + time: { created: 1790656601500 }, + type: "user", + payload: { text: "Also say STEERED." }, + delivery: "steer", + }), + out("session.background", { sessionID: SESSION }), + reply("session.background", null), + // Event order follows an OpenCode 2.1.4 capture, with shortened ids and texts. + returnedRunning(call.id), + event("session.inbox.enqueued", { + inboxID: "msg_backgrounded", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: "User requested that active blocking work be moved to the background.", + }, + delivery: "steer", + }, + }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: steerId }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_backgrounded" }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_answer", + ordinal: 0, + text: "STEERED", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const attached = yield* Deferred.make(); + const answered = yield* Deferred.make(); + const ended = yield* Deferred.make(); + const sleep: Array = []; + yield* runtime.events.pipe( + Stream.tap((event) => { + if (event.type === "subagent.updated" && event.subagent.title === "Sleep") { + sleep.push(event.subagent.status); + if (event.subagent.childThreadId !== null) return Deferred.succeed(attached, undefined); + } + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === "STEERED" + ) { + return Deferred.succeed(answered, undefined); + } + if (event.type === "turn.terminal") return Deferred.succeed(ended, event.status); + return Effect.void; + }), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(attached); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run:opencode2-adapter"), + providerThread: thread, + providerTurnId: yield* providerTurnId, + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:steer"), + text: "Also say STEERED.", + }, + }); + assert.equal(yield* Deferred.await(ended), "completed"); + yield* Deferred.await(answered); + assert.equal(sleep.at(-1), "running"); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + const MIDDLE = "ses_middle0000000000000000000"; const DEEP = "ses_grandchild00000000000000"; const toolOf = (session: string, id: string) => ({ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index fd868921f744..343d5a8f6bd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -91,6 +91,7 @@ const operationOf = ( if (method === "POST" && rest === "/command") return { type: "session.command", input }; if (method === "POST" && rest === "/compact") return { type: "session.compact", input }; if (method === "POST" && rest === "/interrupt") return { type: "session.interrupt", input }; + if (method === "POST" && rest === "/background") return { type: "session.background", input }; if (method === "POST" && rest === "/model") return { type: "session.switchModel", input }; if (method === "POST" && rest === "/agent") return { type: "session.switchAgent", input }; if (method === "POST" && rest === "/move") return { type: "session.move", input }; diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index dba02e02fa0b..7c0244c76119 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3684,6 +3684,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ), ); + // A foreground subagent holds the steer until it returns; backgrounding it lets the parent answer now. + const blocked = [...state.calls.values()].some( + (call) => call.turn === turn && !call.background && call.status === "running", + ); + if (blocked && turn.steers.has(inboxID)) { + yield* client.session + .background({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.ignore({ log: true })); + } }, (effect, steerInput) => effect.pipe( From 0058d1ae2b206f4aa655c476420c8b49150e265d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 16:57:29 -0500 Subject: [PATCH 2/3] fix(opencode): a stalled background request no longer holds a steer open Steering during a foreground subagent asks OpenCode to background the blocking work. That request had no deadline, so an OpenCode server that never answered it left the steer operation pending forever. It now uses the same 10-second reply deadline as the adapter's other best-effort requests; the steer itself is already admitted and stays delivered. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 196 +++++++++++------- .../src/server/v2/adapter.ts | 2 +- 2 files changed, 124 insertions(+), 74 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index c1d755f7db89..098f5593f5db 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1105,67 +1105,46 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - // regression: #22331 a steer sent while a foreground subagent ran was never answered - it.effect("moves a foreground subagent to the background so the parent answers a steer", () => + const foregroundCall = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-fg" }; + const foregroundSteerId = "msg_recorded_steer"; + const steerDuringForeground: ReadonlyArray = [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...foregroundCall, name: "subagent" }), + event("session.tool.called", { + ...foregroundCall, + name: "subagent", + input: { description: "Sleep", prompt: "sleep" }, + executed: false, + }), + event("session.created", childCreated(CHILD)), + event("session.tool.progress", { + ...foregroundCall, + metadata: { sessionID: CHILD, status: "running" }, + }), + event("session.execution.started", { sessionID: CHILD }), + out("session.prompt", { + sessionID: SESSION, + id: foregroundSteerId, + text: "", + delivery: "steer", + }), + replyData("session.prompt", { + id: foregroundSteerId, + sessionID: SESSION, + time: { created: 1790656601500 }, + type: "user", + payload: { text: "Also say STEERED." }, + delivery: "steer", + }), + out("session.background", { sessionID: SESSION }), + ]; + const steerForeground = ( + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + thread: OrchestrationV2ProviderThread, + ) => Effect.gen(function* () { - const call = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-fg" }; - const steerId = "msg_recorded_steer"; - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.started", { sessionID: SESSION }), - event("session.tool.input.started", { ...call, name: "subagent" }), - event("session.tool.called", { - ...call, - name: "subagent", - input: { description: "Sleep", prompt: "sleep" }, - executed: false, - }), - event("session.created", childCreated(CHILD)), - event("session.tool.progress", { - ...call, - metadata: { sessionID: CHILD, status: "running" }, - }), - event("session.execution.started", { sessionID: CHILD }), - out("session.prompt", { - sessionID: SESSION, - id: steerId, - text: "", - delivery: "steer", - }), - replyData("session.prompt", { - id: steerId, - sessionID: SESSION, - time: { created: 1790656601500 }, - type: "user", - payload: { text: "Also say STEERED." }, - delivery: "steer", - }), - out("session.background", { sessionID: SESSION }), - reply("session.background", null), - // Event order follows an OpenCode 2.1.4 capture, with shortened ids and texts. - returnedRunning(call.id), - event("session.inbox.enqueued", { - inboxID: "msg_backgrounded", - sessionID: SESSION, - item: { - type: "synthetic", - payload: { - text: "User requested that active blocking work be moved to the background.", - }, - delivery: "steer", - }, - }), - event("session.inbox.delivered", { sessionID: SESSION, inboxID: steerId }), - event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_backgrounded" }), - event("session.text.ended", { - sessionID: SESSION, - assistantMessageID: "msg_assistant_answer", - ordinal: 0, - text: "STEERED", - }), - event("session.execution.succeeded", { sessionID: SESSION }), - ]); const attached = yield* Deferred.make(); const answered = yield* Deferred.make(); const ended = yield* Deferred.make(); @@ -1191,24 +1170,95 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); yield* runtime.startTurn(withLineage(thread)); yield* Deferred.await(attached); - yield* runtime.steerTurn({ - threadId, - runId: RunId.make("run:opencode2-adapter"), - providerThread: thread, - providerTurnId: yield* providerTurnId, - message: { - ...turnInput(thread).message, - messageId: MessageId.make("message:opencode2-adapter:steer"), - text: "Also say STEERED.", - }, - }); - assert.equal(yield* Deferred.await(ended), "completed"); - yield* Deferred.await(answered); + const steer = yield* runtime + .steerTurn({ + threadId, + runId: RunId.make("run:opencode2-adapter"), + providerThread: thread, + providerTurnId: yield* providerTurnId, + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:steer"), + text: "Also say STEERED.", + }, + }) + .pipe(Effect.forkScoped); + return { + steer, + answered: Deferred.await(answered), + ended: Deferred.await(ended), + sleep, + }; + }); + + // regression: #22331 a steer sent while a foreground subagent ran was never answered + it.effect("moves a foreground subagent to the background so the parent answers a steer", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...steerDuringForeground, + reply("session.background", null), + // Event order follows an OpenCode 2.1.4 capture, with shortened ids and texts. + returnedRunning(foregroundCall.id), + event("session.inbox.enqueued", { + inboxID: "msg_backgrounded", + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: "User requested that active blocking work be moved to the background.", + }, + delivery: "steer", + }, + }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: foregroundSteerId }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_backgrounded" }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_answer", + ordinal: 0, + text: "STEERED", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const { steer, answered, ended, sleep } = yield* steerForeground(runtime, thread); + yield* Fiber.join(steer); + assert.equal(yield* ended, "completed"); + yield* answered; assert.equal(sleep.at(-1), "running"); assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); }).pipe(Effect.scoped), ); + it.effect("a steer returns when OpenCode never answers the background request", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...steerDuringForeground, + reply("session.background", ""), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.tool.success", { + ...foregroundCall, + content: [{ type: "text", text: `` }], + metadata: { sessionID: CHILD, status: "completed" }, + executed: false, + }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: foregroundSteerId }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_answer", + ordinal: 0, + text: "STEERED", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const { steer, answered, ended } = yield* steerForeground(runtime, thread); + // The replay emits the answer only after the background request reached it. + yield* answered; + assert.equal(yield* ended, "completed"); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(steer); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + const MIDDLE = "ses_middle0000000000000000000"; const DEEP = "ses_grandchild00000000000000"; const toolOf = (session: string, id: string) => ({ diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 7c0244c76119..c657fa1da31e 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3691,7 +3691,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (blocked && turn.steers.has(inboxID)) { yield* client.session .background({ sessionID: Session.ID.make(sessionId) }) - .pipe(Effect.ignore({ log: true })); + .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); } }, (effect, steerInput) => From f7f6844785d2bc25a4fe8c2b8b7bc497f6c15ed0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 17:10:19 -0500 Subject: [PATCH 3/3] test(opencode): the stalled-background steer test fails by assertion, not timeout Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 098f5593f5db..e02def7abe11 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1255,6 +1255,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { yield* answered; assert.equal(yield* ended, "completed"); yield* TestClock.adjust("10 seconds"); + // TestClock resumes an expired sleep synchronously, so a bounded steer has returned by now. + assert.isDefined(steer.pollUnsafe(), "the steer still waits for the background reply"); yield* Fiber.join(steer); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), );