From 165ae58f75602dc3448656ffa09f7037250588b4 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Thu, 16 Apr 2026 13:01:56 +1000 Subject: [PATCH] fix(server): Improve OpenCode permission prompt context (#398) Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer. --- .../server/agent/providers/opencode-agent.ts | 145 +++++++++++++++++- .../opencode/event-translator.test.ts | 108 +++++++++++++ .../shared/messages.stream-parsing.test.ts | 38 +++++ packages/server/src/shared/messages.ts | 1 + 4 files changed, 289 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c2953b422..83cc5b7f0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -36,6 +36,7 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, + ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { @@ -1191,6 +1192,118 @@ function createCompactionTimelineItem( }; } +const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const; +const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const; +const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const; +const PERMISSION_TITLE_BY_NAME: Record = { + external_directory: "Access external directory", + bash: "Run shell command", + read: "Read files", + read_file: "Read files", + write: "Write files", + write_file: "Write files", + create_file: "Write files", + edit: "Edit files", + apply_patch: "Edit files", + apply_diff: "Edit files", +}; + +function toHumanReadablePermissionTitle(permission: string): string { + const mapped = PERMISSION_TITLE_BY_NAME[permission]; + if (mapped) { + return mapped; + } + + const normalized = permission + .split(/[\s_-]+/) + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join(" "); + return normalized.length > 0 ? normalized : "Permission request"; +} + +function readFirstStringFromRecord( + record: Record | null, + keys: readonly string[], +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = readNonEmptyString(record[key]); + if (value) { + return value; + } + } + return null; +} + +function readPermissionField( + metadata: Record | null, + keys: readonly string[], +): string | null { + const direct = readFirstStringFromRecord(metadata, keys); + if (direct) { + return direct; + } + + const nestedInput = readOpenCodeRecord(metadata?.input); + return readFirstStringFromRecord(nestedInput, keys); +} + +function buildOpenCodePermissionInput(params: { + patterns: string[]; + metadata: Record | null; + tool: Record | null; + command: string | null; +}): Record { + return { + ...(params.patterns.length > 0 ? { patterns: params.patterns } : {}), + ...(params.metadata ? { metadata: params.metadata } : {}), + ...(params.tool ? { tool: params.tool } : {}), + ...(params.command ? { command: params.command } : {}), + }; +} + +function buildOpenCodePermissionDetail(params: { + permission: string; + input: Record; + command: string | null; + cwd: string | null; +}): ToolCallDetail { + if (params.command) { + return { + type: "shell", + command: params.command, + ...(params.cwd ? { cwd: params.cwd } : {}), + }; + } + + return { + type: "unknown", + input: { + permission: params.permission, + ...params.input, + }, + output: null, + }; +} + +function buildOpenCodePermissionDescription(params: { + reason: string | null; + patterns: string[]; +}): string | undefined { + const parts: string[] = []; + if (params.reason) { + parts.push(params.reason); + } + if (params.patterns.length > 0) { + parts.push(`Scope: ${params.patterns.join(", ")}`); + } + return parts.length > 0 ? parts.join(" - ") : undefined; +} + export function translateOpenCodeEvent( event: OpenCodeEvent, state: OpenCodeEventTranslationState, @@ -1353,6 +1466,31 @@ export function translateOpenCodeEvent( break; } + const metadata = readOpenCodeRecord(event.properties.metadata); + const tool = readOpenCodeRecord(event.properties.tool); + const patterns = Array.isArray(event.properties.patterns) + ? event.properties.patterns.filter((value): value is string => typeof value === "string") + : []; + const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS); + const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS); + const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS); + const input = buildOpenCodePermissionInput({ + patterns, + metadata, + tool, + command, + }); + const detail = buildOpenCodePermissionDetail({ + permission: event.properties.permission, + input, + command, + cwd, + }); + const description = buildOpenCodePermissionDescription({ + reason, + patterns, + }); + events.push({ type: "permission_requested", provider: "opencode", @@ -1361,9 +1499,10 @@ export function translateOpenCodeEvent( provider: "opencode", name: event.properties.permission, kind: "tool", - title: event.properties.permission, - description: event.properties.patterns?.join(", "), - input: event.properties.metadata, + title: toHumanReadablePermissionTitle(event.properties.permission), + ...(description ? { description } : {}), + input, + detail, }, }); break; diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 69bd08ce6..7e5522306 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("humanizes permission requests and includes shell detail when command metadata exists", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/home/user/secrets/*"], + metadata: { + command: "ls /home/user/secrets", + reason: "Need to inspect generated files", + }, + tool: { + messageID: "message-1", + callID: "call-1", + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-1", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + description: "Need to inspect generated files - Scope: /home/user/secrets/*", + input: { + patterns: ["/home/user/secrets/*"], + metadata: { + command: "ls /home/user/secrets", + reason: "Need to inspect generated files", + }, + tool: { + messageID: "message-1", + callID: "call-1", + }, + command: "ls /home/user/secrets", + }, + detail: { + type: "shell", + command: "ls /home/user/secrets", + }, + }, + }, + ]); + }); + + it("falls back to unknown permission detail when command metadata is absent", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "permission.asked", + properties: { + id: "perm-2", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-2", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + description: "Need to access temporary checkout - Scope: /tmp/outside/*", + input: { + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + detail: { + type: "unknown", + input: { + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + output: null, + }, + }, + }, + ]); + }); + it("emits usage_updated after step-finish parts", () => { const state = createState(); state.accumulatedUsage.contextWindowMaxTokens = 400_000; diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index c5698c901..4bb6692c7 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -207,6 +207,44 @@ describe("shared messages stream parsing", () => { } }); + it("parses permission request detail compatibly", () => { + const parsed = AgentStreamMessageSchema.parse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:00.000Z", + event: { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-shell-1", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + input: { + command: "ls /tmp/outside", + }, + detail: { + type: "shell", + command: "ls /tmp/outside", + cwd: "/home/dev/project", + }, + }, + }, + }, + }); + + expect(parsed.payload.event.type).toBe("permission_requested"); + if (parsed.payload.event.type === "permission_requested") { + expect(parsed.payload.event.request.detail).toEqual({ + type: "shell", + command: "ls /tmp/outside", + cwd: "/home/dev/project", + }); + } + }); + it("rejects removed initialize_agent_request inbound payload", () => { const parsed = SessionInboundMessageSchema.safeParse({ type: "initialize_agent_request", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index af2280dd7..f9a93e214 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType ToolCallDetailPayloadSchema).optional(), suggestions: z.array(AgentPermissionUpdateSchema).optional(), actions: z.array(AgentPermissionActionSchema).optional(), metadata: z.record(z.unknown()).optional(),