mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user