diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..2b5c0717a193 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -61,6 +61,8 @@ const runtimeMock = { sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], + onAbort: null as ((sessionID: string) => void) | null, + sessionChildrenById: new Map>(), closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, promptCalls: [] as Array, @@ -81,6 +83,8 @@ const runtimeMock = { this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.onAbort = null; + this.state.sessionChildrenById.clear(); this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.promptCalls.length = 0; @@ -176,7 +180,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); + runtimeMock.state.onAbort?.(sessionID); }, + children: async ({ sessionID }: { sessionID: string }) => ({ + data: runtimeMock.state.sessionChildrenById.get(sessionID) ?? [], + }), promptAsync: async (input: unknown) => { runtimeMock.state.promptCalls.push(input); if (runtimeMock.state.promptAsyncError) { @@ -602,6 +610,40 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("interrupts the parent before all surviving OpenCode child sessions", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-children"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + runtimeMock.state.sessionChildrenById.set("http://127.0.0.1:9999/session", [ + { id: "child-a" }, + { id: "child-b" }, + ]); + runtimeMock.state.onAbort = (sessionID) => { + if (sessionID === "http://127.0.0.1:9999/session") { + runtimeMock.state.sessionChildrenById + .get("http://127.0.0.1:9999/session") + ?.push({ id: "child-created-during-parent-abort" }); + } + }; + + yield* adapter.interruptTurn(threadId); + + NodeAssert.equal(runtimeMock.state.abortCalls[0], "http://127.0.0.1:9999/session"); + NodeAssert.deepEqual( + runtimeMock.state.abortCalls.slice(1).sort(), + ["child-a", "child-b", "child-created-during-parent-abort"].sort(), + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits one session.exited event when stopping a session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..9d44256e2ad2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1555,12 +1555,37 @@ export function makeOpenCodeAdapter( }; }); + const abortOpenCodeSessionTree = Effect.fn("abortOpenCodeSessionTree")(function* ( + context: OpenCodeSessionContext, + ) { + // Abort the parent first so it cannot spawn another child after we take the + // children snapshot. Older OpenCode releases do not reliably propagate this + // cancellation, so enumerate and abort any surviving children afterwards. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); + + const children = yield* runOpenCodeSdk("session.children", () => + context.client.session.children({ sessionID: context.openCodeSessionId }), + ).pipe( + Effect.map((response) => response.data ?? []), + Effect.orElseSucceed(() => []), + ); + + yield* Effect.forEach( + children, + (child) => + runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: child.id }), + ).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + }); + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); + yield* abortOpenCodeSessionTree(context); if (turnId ?? context.activeTurnId) { yield* emit({ ...(yield* buildEventBase({