fix(opencode): agent goes idle after a v2 tool permission is denied (#6188)

* fix(opencode): agent goes idle after a v2 tool permission is denied

OpenCode 2.0.20 ends the execution when a tool permission is rejected and
reports the interruption with reason "shutdown", the same reason it uses for
an execution it will resume after a server restart. The turn tracker only
treated "shutdown" as final after a dismissed question, so a denied tool
permission left the agent running with nothing pending, and Stop waited on a
turn that could never complete.

Treat any denied request owned by this session as ending the turn when the
following interruption reports "shutdown". Denials of child-session requests
do not interrupt the parent execution, so they are excluded.

* test(opencode): denying a child session's permission keeps the v2 turn resumable

---------

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 06:08:14 +02:00
committed by GitHub
parent fe10c40b9a
commit e3db7c3397
4 changed files with 126 additions and 12 deletions
@@ -5,6 +5,7 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { V2Api } from "./api.js";
function terminalEvents(events: AgentStreamEvent[]) {
return events.filter(
@@ -266,6 +267,116 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
test("ends the turn when the user denies a tool permission", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = null;
harness.api.permission.list = async () => (permission ? [permission] : []);
harness.api.permission.reply = async () => {
permission = null;
harness.active = false;
// OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason.
harness.push({
id: "denied",
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("run printf");
await expect.poll(() => harness.prompts).toHaveLength(1);
permission = {
id: "approval",
sessionID: "session",
action: "shell",
resources: ["printf TOOL_PERMISSION_QA"],
};
harness.push({
id: "asked",
created: 2,
type: "permission.asked",
data: permission,
});
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("approval", { 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("keeps the turn resumable after the user denies a child session's permission", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
const child = { ...harness.info, id: "child", parentID: "session" };
harness.api.session.list = async (input) => ({
data: input?.parentID === "session" ? [child] : [],
cursor: {},
});
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = {
id: "child-approval",
sessionID: "child",
action: "shell",
resources: ["pwd"],
};
harness.api.permission.list = async (input) =>
permission && input.sessionID === "child" ? [permission] : [];
harness.api.permission.reply = async () => {
permission = null;
};
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("delegate work");
await expect.poll(() => harness.prompts).toHaveLength(1);
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("child-approval", { behavior: "deny" });
harness.active = false;
harness.push({
id: "shutdown",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "session", seq: 3, version: 1 },
data: { sessionID: "session", reason: "shutdown" },
});
const reads = harness.activeReads;
harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} });
await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
expect(terminalEvents(events)).toEqual([]);
harness.startExecution();
harness.finishExecution();
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
expect(terminalEvents(events)[0]).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,8 +33,11 @@ export class SessionPermissions {
list() {
return [...this.pending.values()];
}
isQuestion(requestId: string, sessionID: string) {
return this.forms.get(requestId)?.sessionID === sessionID;
isOwnedBy(requestId: string, sessionID: string) {
return (
this.forms.get(requestId)?.sessionID === sessionID ||
this.permissionOwners.get(requestId) === sessionID
);
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
const form = this.forms.get(requestId);
@@ -405,10 +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);
const deniesOwnRequest =
response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id);
await this.permissions.respondToPermission(requestId, response);
if (dismissesQuestion) this.turns.questionDismissed();
if (deniesOwnRequest) this.turns.requestDenied();
}
private async reconcileSnapshot() {
const [info, history] = await Promise.all([
@@ -60,7 +60,7 @@ interface Turn {
completion: Promise<void>;
settle(): void;
accepted: boolean;
questionDismissed: boolean;
requestDenied: boolean;
}
export class SessionTurns {
@@ -113,7 +113,7 @@ export class SessionTurns {
settle,
accepted,
output,
questionDismissed: false,
requestDenied: false,
};
this.turn = turn;
this.execution = null;
@@ -164,11 +164,11 @@ 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() {
// OpenCode ends the execution when the user denies its permission or dismisses its question,
// but reports the interruption as "shutdown", the same reason as an execution it will resume.
requestDenied() {
if (!this.turn) return;
this.turn.questionDismissed = true;
this.turn.requestDenied = true;
this.requestReconciliation();
}
private requestReconciliation() {
@@ -227,7 +227,7 @@ export class SessionTurns {
execution.type === "session.execution.started" ||
(execution.type === "session.execution.interrupted" &&
execution.data.reason === "shutdown" &&
!turn.questionDismissed)
!turn.requestDenied)
)
return;
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.