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>
This commit is contained in:
paseo-bot[bot]
2026-10-06 01:06:55 +02:00
committed by GitHub
parent 3ad878b47f
commit 7a30305503
4 changed files with 75 additions and 2 deletions
@@ -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;
@@ -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) {
@@ -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([
@@ -60,6 +60,7 @@ interface Turn {
completion: Promise<void>;
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.