From 2fac8313c88aacc648583b2b93e174484f8a7623 Mon Sep 17 00:00:00 2001 From: Mina Sayed Date: Mon, 31 Aug 2026 01:52:59 +0300 Subject: [PATCH] test(server): add regression for OpenCode interrupt returning to ready Covers the Stop button fix where a successful interrupt must emit turn.aborted and move the provider session back to ready so the following turn can be sent. The existing harness already records abortCalls; this pins the happy path that was missing. --- .../provider/Layers/OpenCodeAdapter.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9823a68708c2..c7ac1e41f2bd 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -69,6 +69,7 @@ const runtimeMock = { abortImplementation: null as | ((sessionID: string, signal?: AbortSignal) => Promise) | null, + abortError: null as Error | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, @@ -116,6 +117,7 @@ const runtimeMock = { this.state.abortCalls.length = 0; this.state.abortSignals.length = 0; this.state.abortImplementation = null; + this.state.abortError = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; @@ -250,6 +252,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { if (options?.signal) { runtimeMock.state.abortSignals.push(options.signal); } + if (runtimeMock.state.abortError) { + throw runtimeMock.state.abortError; + } await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); }, status: async () => { @@ -1352,6 +1357,61 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("interrupt emits turn.aborted and returns session to ready", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-ready"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "write a very long story about space, 5000 words", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/sonnet", + ), + }); + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.interruptTurn(threadId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const aborted = events.find((event) => event.type === "turn.aborted"); + NodeAssert.ok(aborted); + if (aborted?.type === "turn.aborted") { + NodeAssert.equal(String(aborted.turnId), String(turn.turnId)); + } + + NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + // Following turn can be sent after interrupt. + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "next prompt after interrupt", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/sonnet", + ), + }); + NodeAssert.ok(nextTurn.turnId); + NodeAssert.notEqual(String(nextTurn.turnId), String(turn.turnId)); + }), + ); + it.effect("does not let an old idle status complete a successful steer", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter;