Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderReplayEntry> = [
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
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: "<any>",
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<void>();
const answered = yield* Deferred.make<void>();
const ended = yield* Deferred.make<string>();
const sleep: Array<string> = [];
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", "<hang>"),
event("session.execution.succeeded", { sessionID: CHILD }),
event("session.tool.success", {
...foregroundCall,
content: [{ type: "text", text: `<subagent sessionID="${CHILD}" state="completed">` }],
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) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
9 changes: 9 additions & 0 deletions packages/provider-opencode/src/server/v2/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading