From 7a30305503c600bc46ea2a94a6750eac5cede278 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:06:55 +0200 Subject: [PATCH] fix(opencode): agent goes idle after a v2 question is dismissed (#6177) * fix(opencode): end the v2 turn when the user dismisses a question OpenCode ends the execution when a question is dismissed but reports the interruption with the reason "shutdown", which the turn tracker keeps open waiting for a resume that never comes. The agent stayed running, and Stop, reload and send were refused. A question dismissed from Paseo now lets that interruption end the turn as canceled. * test(opencode): cover the next message after a dismissed v2 question --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../agent/providers/opencode/v2/agent.test.ts | 58 ++++++++++++++++++- .../providers/opencode/v2/permissions.ts | 3 + .../agent/providers/opencode/v2/session.ts | 3 + .../agent/providers/opencode/v2/turns.ts | 13 ++++- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 8c91854fb8..9692f33b61 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1,4 +1,4 @@ -import type { ModelInfo, SessionMessageInfo } from "@opencode/client"; +import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../../test-utils/test-logger.js"; @@ -210,6 +210,62 @@ describe("OpenCode v2 session lifecycle", () => { } }); + test("ends the turn when the user dismisses a question", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let form: FormInfo | null = null; + harness.api.session.form.list = async () => (form ? [form] : []); + harness.api.session.form.cancel = async () => { + form = null; + harness.active = false; + // OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason. + harness.push({ + id: "dismissed", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("ask me something"); + await expect.poll(() => harness.prompts).toHaveLength(1); + form = { + id: "question", + sessionID: "session", + title: "Color", + fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }], + }; + harness.push({ + id: "form", + created: 2, + type: "form.created", + data: { form }, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("question", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => { const harness = new V2Harness(); harness.autoComplete = false; diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index 176fc455be..26f72b77ad 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -33,6 +33,9 @@ export class SessionPermissions { list() { return [...this.pending.values()]; } + isQuestion(requestId: string, sessionID: string) { + return this.forms.get(requestId)?.sessionID === sessionID; + } async respondToPermission(requestId: string, response: AgentPermissionResponse) { const form = this.forms.get(requestId); if (form) { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index cb71ff7212..5db5a504e4 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -405,7 +405,10 @@ export class OpenCodeV2Session implements AgentSession { } async respondToPermission(requestId: string, response: AgentPermissionResponse) { await this.reconnectIfExited(); + const dismissesQuestion = + response.behavior === "deny" && this.permissions.isQuestion(requestId, this.id); await this.permissions.respondToPermission(requestId, response); + if (dismissesQuestion) this.turns.questionDismissed(); } private async reconcileSnapshot() { const [info, history] = await Promise.all([ diff --git a/packages/server/src/server/agent/providers/opencode/v2/turns.ts b/packages/server/src/server/agent/providers/opencode/v2/turns.ts index 5c8765ec02..a1d7b3d80a 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/turns.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts @@ -60,6 +60,7 @@ interface Turn { completion: Promise; settle(): void; accepted: boolean; + questionDismissed: boolean; } export class SessionTurns { @@ -112,6 +113,7 @@ export class SessionTurns { settle, accepted, output, + questionDismissed: false, }; this.turn = turn; this.execution = null; @@ -162,6 +164,13 @@ export class SessionTurns { this.sequence = event.durable.seq; this.requestReconciliation(); } + // OpenCode ends the execution when the user dismisses its question, but reports the + // interruption as "shutdown", the same reason as an execution it will resume. + questionDismissed() { + if (!this.turn) return; + this.turn.questionDismissed = true; + this.requestReconciliation(); + } private requestReconciliation() { void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error)); } @@ -216,7 +225,9 @@ export class SessionTurns { this.sequence = execution.durable.seq; if ( execution.type === "session.execution.started" || - (execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown") + (execution.type === "session.execution.interrupted" && + execution.data.reason === "shutdown" && + !turn.questionDismissed) ) return; // Read the final history only after observing idle. Never turn an observation transport error into an execution failure.