diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index ca0288ab2c5e6..e02def7abe119 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1105,6 +1105,162 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + 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 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); + 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"); + // 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())), + ); + 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 fd868921f7442..343d5a8f6bd1d 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 dba02e02fa0b1..c657fa1da31e1 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.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true })); + } }, (effect, steerInput) => effect.pipe(