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 9692f33b61..26cb65885d 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 @@ -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>[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>[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; 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 26f72b77ad..cc13b99e4b 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -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); 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 5db5a504e4..5cd45b1ff7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -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([ 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 a1d7b3d80a..2b2c21d0c5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/turns.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts @@ -60,7 +60,7 @@ interface Turn { completion: Promise; 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.