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.
This commit is contained in:
Aaron Florey
2026-04-16 11:01:56 +08:00
committed by GitHub
parent a1f3256923
commit 165ae58f75
4 changed files with 289 additions and 3 deletions
@@ -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<string, string> = {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null;
tool: Record<string, unknown> | null;
command: string | null;
}): Record<string, unknown> {
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<string, unknown>;
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;
@@ -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;
@@ -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",
+1
View File
@@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
title: z.string().optional(),
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
detail: z.lazy(() => ToolCallDetailPayloadSchema).optional(),
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
actions: z.array(AgentPermissionActionSchema).optional(),
metadata: z.record(z.unknown()).optional(),