From 6f08a145d3903514685b82ba210a77e613f7a6bb Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:21:13 +0000 Subject: [PATCH] fix(opencode): preserve denial reasons when a message supersedes approval --- .../providers/opencode/v2/permissions.test.ts | 96 +++++++++++++++++++ .../providers/opencode/v2/permissions.ts | 3 + .../agent/providers/opencode/v2/session.ts | 6 +- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts index 69da960c5c..2bf197c88d 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js"; import type { V2Api } from "./api.js"; import { OpenCodeV2AgentClient } from "./agent.js"; import { V2Harness } from "../test-utils/v2-harness.js"; +import type { AgentPermissionResponse } from "../../../agent-sdk-types.js"; + +describe("OpenCode v2 denial reasons", () => { + test.each<{ + response: AgentPermissionResponse; + decision: "reject" | "once" | "always"; + message?: string; + }>([ + { + response: { behavior: "deny", message: "Use the existing file instead." }, + decision: "reject", + message: "Use the existing file instead.", + }, + { response: { behavior: "deny" }, decision: "reject" }, + { response: { behavior: "allow" }, decision: "once" }, + { response: { behavior: "allow", selectedActionId: "always" }, decision: "always" }, + ])("preserves a permission response: $response", async ({ response, decision, message }) => { + const harness = new V2Harness(); + let pending = true; + harness.api.permission.list = async () => + pending + ? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }] + : []; + const replies: Parameters[0][] = []; + harness.api.permission.reply = async (input) => { + replies.push(input); + pending = false; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.respondToPermission("approval", response); + expect(replies).toEqual([ + { + sessionID: "session", + requestID: "approval", + decision, + ...(message === undefined ? {} : { message }), + }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); + + test.each([true, false])( + "explains cards superseded by a steer when clearing is %s", + async (clearPendingPermissions) => { + const harness = new V2Harness(); + harness.autoComplete = false; + let pending = true; + harness.api.permission.list = async () => + pending + ? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }] + : []; + const replies: Parameters[0][] = []; + harness.api.permission.reply = async (input) => { + replies.push(input); + pending = false; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const { turnId } = await session.startTurn("Run pwd"); + await expect.poll(() => harness.prompts).toEqual(["Run pwd"]); + await expect( + session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }), + ).resolves.toEqual({ status: "accepted" }); + expect(harness.prompts).toEqual(["Run pwd", "go ahead"]); + expect(replies).toEqual( + clearPendingPermissions + ? [ + { + sessionID: "session", + requestID: "approval", + decision: "reject", + message: + "The user answered with a message instead of approving. Their message follows.", + }, + ] + : [], + ); + expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1); + } finally { + await session.close(); + } + }, + ); +}); describe("OpenCode v2 questions", () => { test("maps selected labels to native values and parses numeric and boolean answers", async () => { 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 cb33e0652e..ec77052cb5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -68,6 +68,9 @@ export class SessionPermissions { sessionID: this.permissionOwners.get(requestId) ?? this.id, requestID: requestId, decision: permissionReply(response), + ...(response.behavior === "deny" && response.message !== undefined + ? { message: response.message } + : {}), }); } this.resolvePending(requestId, response); 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 a167ec0713..7d03e9baaa 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession { ), clearPermissions: async () => { for (const request of this.permissions.list()) - await this.permissions.respondToPermission(request.id, { behavior: "deny" }); + await this.permissions.respondToPermission(request.id, { + behavior: "deny", + message: + "The user answered with a message instead of approving. Their message follows.", + }); }, }); this.usage = new SessionUsage({