Skip to content

Commit b0945d5

Browse files
fix(opencode): a Stop OpenCode 2 never answers offers Force stop
When session.interrupt got no reply within 10 s, the OpenCode 2 adapter ended the turn as interrupted anyway, so the UI said the run stopped while the tool's process tree kept running and Force stop was never offered. The adapter now fails the Stop with ProviderAdapterInterruptTimeoutError and leaves the turn open, as the Claude and ACP adapters do. Stop control then reports the provider as unresponsive and offers Force stop; a late end from OpenCode still finishes the turn as interrupted. Fixes #30193 AI-assisted; verified with the tests and tools listed above.
1 parent b38de69 commit b0945d5

2 files changed

Lines changed: 48 additions & 27 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts‎

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,7 +2202,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
22022202
.interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
22032203
.pipe(Effect.forkScoped);
22042204
yield* TestClock.adjust("11 seconds");
2205-
yield* Fiber.join(interrupt);
2205+
yield* Fiber.join(interrupt).pipe(Effect.flip);
22062206
});
22072207
const terminals = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, count: number) =>
22082208
runtime.events.pipe(
@@ -2237,21 +2237,37 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
22372237

22382238
it.effect("never lets a timed-out Stop's late end finish the next turn", () =>
22392239
Effect.gen(function* () {
2240-
const { runtime, thread } = yield* resumed([
2241-
...stopTimedOut,
2242-
out("session.active"),
2243-
reply("session.active", { data: {} }),
2244-
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2245-
promptAccepted,
2246-
event("session.execution.succeeded", { sessionID: SESSION }),
2247-
event("session.execution.started", { sessionID: SESSION }),
2248-
event("session.execution.failed", {
2249-
sessionID: SESSION,
2250-
error: { type: "provider", message: "second turn failed" },
2251-
}),
2252-
]);
2253-
const ended = yield* terminals(runtime, 2);
2240+
const gate = makeProviderReplayGate(["late-end"]);
2241+
const { runtime, thread } = yield* resumed(
2242+
[
2243+
...stopTimedOut,
2244+
event("session.execution.succeeded", { sessionID: SESSION }, "late-end"),
2245+
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2246+
promptAccepted,
2247+
event("session.execution.started", { sessionID: SESSION }),
2248+
event("session.execution.failed", {
2249+
sessionID: SESSION,
2250+
error: { type: "provider", message: "second turn failed" },
2251+
}),
2252+
],
2253+
{ replayGate: gate },
2254+
);
2255+
const lateEnd = yield* Deferred.make<void>();
2256+
const ended = yield* runtime.events.pipe(
2257+
Stream.filter(
2258+
(
2259+
event,
2260+
): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
2261+
event.type === "turn.terminal",
2262+
),
2263+
Stream.tap(() => Deferred.succeed(lateEnd, undefined)),
2264+
Stream.take(2),
2265+
Stream.runCollect,
2266+
Effect.forkScoped,
2267+
);
22542268
yield* stopFirstTurn(runtime, thread);
2269+
gate.release("late-end");
2270+
yield* Deferred.await(lateEnd);
22552271
yield* runtime.startTurn(secondTurn(thread));
22562272
const [first, second] = yield* Fiber.join(ended);
22572273
assert.equal(first?.status, "interrupted");
@@ -2323,15 +2339,15 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
23232339
...backgroundLaunch(CHILD),
23242340
out("session.interrupt", { sessionID: SESSION }),
23252341
reply("session.interrupt", "<hang>"),
2326-
out("session.active"),
2327-
replyData("session.active", { [SESSION]: { type: "running" } }),
23282342
out("session.interrupt", { sessionID: SESSION }),
23292343
reply("session.interrupt", { interrupted: true }),
23302344
event("session.usage.updated", { sessionID: SESSION }, "drop"),
23312345
{ type: "runtime_exit", status: "success" } as const,
23322346
out("event.subscribe"),
23332347
out("session.active"),
23342348
replyData("session.active", { [CHILD]: { type: "running" } }),
2349+
out("message.list", "<any>"),
2350+
reply("message.list", { data: [], cursor: {} }),
23352351
out("permission.list", { sessionID: CHILD }),
23362352
replyData("permission.list", []),
23372353
out("session.form.list", { sessionID: CHILD }),
@@ -2393,23 +2409,22 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
23932409
);
23942410
yield* runtime.startTurn(withLineage(thread));
23952411
yield* Deferred.await(attached);
2396-
const interrupt = yield* runtime
2397-
.interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
2398-
.pipe(Effect.forkScoped);
2412+
const stop = { providerThread: thread, providerTurnId: yield* providerTurnId };
2413+
const interrupt = yield* runtime.interruptTurn(stop).pipe(Effect.flip, Effect.forkScoped);
23992414
yield* TestClock.adjust("11 seconds");
24002415
yield* Fiber.join(interrupt);
2401-
yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
2416+
yield* runtime.interruptTurn(stop);
24022417
gate.release("drop");
24032418
yield* runtime.startTurn({
24042419
...continuationTurn(thread, yield* Deferred.await(offered)),
2405-
runOrdinal: 3,
2406-
providerTurnOrdinal: 3,
2420+
runOrdinal: 2,
2421+
providerTurnOrdinal: 2,
24072422
});
24082423
gate.release("reply");
24092424
const events = [...(yield* Fiber.join(collected))];
24102425
assert.deepEqual(
24112426
events.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])),
2412-
["interrupted", "failed", "completed"],
2427+
["interrupted", "completed"],
24132428
);
24142429
assert.include(
24152430
events.flatMap((event) =>

‎packages/provider-opencode/src/server/v2/adapter.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ const claudeUsageLimit = (message: string | undefined) =>
583583
? { resetAt: /\bResets at (\d{4}-\d\d-\d\dT[\d:.]+Z)\.?\s*$/.exec(message)?.[1] ?? null }
584584
: undefined;
585585

586-
const INTERRUPT_TIMEOUT = "10 seconds";
586+
const INTERRUPT_TIMEOUT = 10_000;
587587
const INSTRUCTIONS_KEY = "t3-code";
588588
const RECONNECT_ATTEMPTS = 5;
589589
const RECONNECT_DELAY = "2 seconds";
@@ -4815,8 +4815,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
48154815
),
48164816
);
48174817
if (reply._tag === "None") {
4818-
markUnsettled(state);
4819-
return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
4818+
if (state.active !== turn) return;
4819+
// The turn stays open: its tools may still run, and the user decides whether to Force stop.
4820+
return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({
4821+
driver: OPENCODE_PROVIDER,
4822+
providerThreadId: interruptInput.providerThread.id,
4823+
providerTurnId: interruptInput.providerTurnId,
4824+
timeoutMs: INTERRUPT_TIMEOUT,
4825+
});
48204826
}
48214827
// Stop between steer executions retracts pending steers and skips any run they already started.
48224828
if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) {

0 commit comments

Comments
 (0)