diff --git a/src/app-server/protocol/server-requests.ts b/src/app-server/protocol/server-requests.ts index c53c3db3..c7b6c038 100644 --- a/src/app-server/protocol/server-requests.ts +++ b/src/app-server/protocol/server-requests.ts @@ -1,15 +1,19 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest"; import type { ApprovalAction, - CommandApprovalDecision, + ApprovalActionIntent, + ApprovalDetailRow, McpElicitationAction, McpElicitationContentValue, PendingApproval, + PendingApprovalOption, PendingMcpElicitation, PendingMcpElicitationField, PendingMcpElicitationOption, PendingUserInput, } from "../../domain/pending-requests/model"; +import { pathRelativeToRoot } from "../../shared/path/file-paths"; +import { jsonPreview } from "../../utils"; type AppServerRequestByMethod = Extract; @@ -18,6 +22,13 @@ interface AppServerGrantedPermissionProfile { fileSystem?: unknown; } +type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel"; +type CommandApprovalRequest = AppServerRequestByMethod<"item/commandExecution/requestApproval">; +type CommandApprovalParams = CommandApprovalRequest["params"]; +type CommandApprovalDecision = NonNullable[number]; +type FileChangeApprovalParams = AppServerRequestByMethod<"item/fileChange/requestApproval">["params"]; +type PermissionsApprovalParams = AppServerRequestByMethod<"item/permissions/requestApproval">["params"]; + export type AppServerApprovalResponse = | { decision: CommandApprovalDecision } | { scope: "session" | "turn"; permissions: AppServerGrantedPermissionProfile }; @@ -39,56 +50,25 @@ export interface AppServerMcpElicitationResponse { export function appServerApprovalRequest(request: ServerRequest): PendingApproval | null { switch (request.method) { case "item/commandExecution/requestApproval": - return { - requestId: request.id, - method: request.method, - params: request.params, - }; + return commandApprovalRequest(request.id, request.params); case "item/fileChange/requestApproval": - return { - requestId: request.id, - method: request.method, - params: { - ...request.params, - reason: request.params.reason ?? null, - grantRoot: request.params.grantRoot ?? null, - }, - }; + return fileChangeApprovalRequest(request.id, request.params); case "item/permissions/requestApproval": - return { - requestId: request.id, - method: request.method, - params: request.params, - }; + return permissionsApprovalRequest(request.id, request.params); default: return null; } } export function appServerApprovalResponse(approval: PendingApproval, action: ApprovalAction): AppServerApprovalResponse { - if (approval.method === "item/commandExecution/requestApproval") { - return { - decision: isCommandDecisionAction(action) ? action.decision : commandDecision(action), - }; - } - - if (approval.method === "item/fileChange/requestApproval") { - return { - decision: fileChangeDecision(action), - }; - } - - return { - scope: action === "accept-session" ? "session" : "turn", - permissions: action === "accept" || action === "accept-session" ? grantedPermissions(approval.params.permissions) : {}, - }; + if (isApprovalOptionAction(action)) return action.response as AppServerApprovalResponse; + return approvalResponseForIntent(approval, action); } export function appServerUserInputRequest(request: ServerRequest): PendingUserInput | null { if (request.method !== "item/tool/requestUserInput") return null; return { requestId: request.id, - method: request.method, params: request.params, }; } @@ -115,7 +95,6 @@ export function appServerMcpElicitationRequest(request: ServerRequest): PendingM if (params.mode === "url") { return { requestId: request.id, - method: request.method, params: { threadId: params.threadId, turnId: params.turnId, @@ -130,7 +109,6 @@ export function appServerMcpElicitationRequest(request: ServerRequest): PendingM } return { requestId: request.id, - method: request.method, params: { threadId: params.threadId, turnId: params.turnId, @@ -154,21 +132,92 @@ export function appServerMcpElicitationResponse( }; } -function isCommandDecisionAction(action: ApprovalAction): action is Extract { +function commandApprovalRequest(requestId: PendingApproval["requestId"], params: CommandApprovalParams): PendingApproval { + const details = commandApprovalDetails(params); + return { + requestId, + kind: "command", + turnId: params.turnId, + title: "Command approval", + summary: approvalSummary(params.reason, params.command, "Command execution requested."), + resultSummary: approvalResultSummary(params.reason, params.command, "Command execution requested."), + details, + responses: decisionResponses(commandDecision), + actionOptions: commandApprovalActionOptions(params.availableDecisions), + }; +} + +function fileChangeApprovalRequest(requestId: PendingApproval["requestId"], params: FileChangeApprovalParams): PendingApproval { + const reason = params.reason ?? null; + const grantRoot = params.grantRoot ?? null; + return { + requestId, + kind: "fileChange", + turnId: params.turnId, + title: "File change approval", + summary: approvalSummary(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"), + resultSummary: approvalResultSummary(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"), + details: grantRoot ? [{ key: "grant root", value: grantRoot }] : [], + responses: decisionResponses(fileChangeDecision), + actionOptions: null, + }; +} + +function permissionsApprovalRequest(requestId: PendingApproval["requestId"], params: PermissionsApprovalParams): PendingApproval { + const details: ApprovalDetailRow[] = []; + addOptional(details, "reason", params.reason); + addOptional(details, "cwd", params.cwd); + addOptional(details, "environment", params.environmentId); + details.push(...permissionRows(params.permissions)); + return { + requestId, + kind: "permission", + turnId: params.turnId, + title: "Permission approval", + summary: approvalSummary(params.reason, `cwd: ${params.cwd}`, "Permission change requested."), + resultSummary: approvalResultSummary(params.reason, `cwd: ${params.cwd}`, "Permission change requested."), + details, + responses: { + accept: { scope: "turn", permissions: grantedPermissions(params.permissions) }, + acceptSession: { scope: "session", permissions: grantedPermissions(params.permissions) }, + decline: { scope: "turn", permissions: {} }, + cancel: { scope: "turn", permissions: {} }, + }, + actionOptions: null, + }; +} + +function decisionResponses(decision: (intent: ApprovalActionIntent) => SimpleApprovalDecision): PendingApproval["responses"] { + return { + accept: { decision: decision("accept") }, + acceptSession: { decision: decision("accept-session") }, + decline: { decision: decision("decline") }, + cancel: { decision: decision("cancel") }, + }; +} + +function approvalResponseForIntent(approval: PendingApproval, intent: ApprovalActionIntent): AppServerApprovalResponse { + if (intent === "accept") return approval.responses.accept as AppServerApprovalResponse; + if (intent === "accept-session") return approval.responses.acceptSession as AppServerApprovalResponse; + if (intent === "cancel") return approval.responses.cancel as AppServerApprovalResponse; + return approval.responses.decline as AppServerApprovalResponse; +} + +function isApprovalOptionAction(action: ApprovalAction): action is Extract { return typeof action === "object"; } -function commandDecision(action: ApprovalAction): CommandApprovalDecision { - if (action === "accept") return "accept"; - if (action === "accept-session") return "acceptForSession"; - if (action === "cancel") return "cancel"; +function commandDecision(intent: ApprovalActionIntent): SimpleApprovalDecision { + if (intent === "accept") return "accept"; + if (intent === "accept-session") return "acceptForSession"; + if (intent === "cancel") return "cancel"; return "decline"; } -function fileChangeDecision(action: ApprovalAction): CommandApprovalDecision { - if (action === "accept") return "accept"; - if (action === "accept-session") return "acceptForSession"; - if (action === "cancel") return "cancel"; +function fileChangeDecision(intent: ApprovalActionIntent): SimpleApprovalDecision { + if (intent === "accept") return "accept"; + if (intent === "accept-session") return "acceptForSession"; + if (intent === "cancel") return "cancel"; return "decline"; } @@ -179,6 +228,241 @@ function grantedPermissions(requested: { network?: unknown; fileSystem?: unknown return granted; } +interface CommandAction { + type: string; + command?: unknown; + name?: unknown; + path?: unknown; + query?: unknown; +} + +interface NetworkApprovalContext { + host?: unknown; + protocol?: unknown; +} + +function commandApprovalDetails(params: CommandApprovalParams): ApprovalDetailRow[] { + const rows: ApprovalDetailRow[] = []; + addOptional(rows, "reason", params.reason); + addOptional(rows, "command", params.command); + addOptional(rows, "cwd", params.cwd); + addOptional(rows, "network", networkApprovalContextLabel(params.networkApprovalContext)); + rows.push(...commandActionRows(params.commandActions, params.cwd)); + rows.push(...prefixedPermissionRows("additional", params.additionalPermissions)); + addOptional(rows, "future command rule", params.proposedExecpolicyAmendment); + addOptional(rows, "future network rules", networkPolicyAmendmentsLabel(params.proposedNetworkPolicyAmendments)); + return rows; +} + +function commandApprovalActionOptions(decisions: CommandApprovalParams["availableDecisions"]): PendingApprovalOption[] | null { + if (!decisions || decisions.length === 0) return null; + return decisions.map((decision, index) => { + const intent = commandDecisionIntent(decision); + return { + id: `approval-option:${String(index)}:${commandDecisionKey(decision)}`, + label: commandDecisionLabel(decision), + intent, + action: { + kind: "approval-option", + intent, + response: { decision }, + }, + }; + }); +} + +function commandDecisionIntent(decision: CommandApprovalDecision): ApprovalActionIntent { + if (typeof decision === "string") return simpleCommandDecisionIntent(decision); + if ("acceptWithExecpolicyAmendment" in decision) return "accept-session"; + if ("applyNetworkPolicyAmendment" in decision) { + return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "accept-session" : "decline"; + } + return "decline"; +} + +function simpleCommandDecisionIntent(decision: string): ApprovalActionIntent { + if (decision === "accept") return "accept"; + if (decision === "acceptForSession") return "accept-session"; + if (decision === "cancel") return "cancel"; + return "decline"; +} + +function commandDecisionLabel(decision: CommandApprovalDecision): string { + if (typeof decision === "string") return simpleCommandDecisionLabel(decision); + if ("acceptWithExecpolicyAmendment" in decision) return "Allow rule"; + if ("applyNetworkPolicyAmendment" in decision) { + return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "Allow network rule" : "Deny network rule"; + } + return "Choose"; +} + +function simpleCommandDecisionLabel(decision: string): string { + if (decision === "accept") return "Allow"; + if (decision === "acceptForSession") return "Allow session"; + if (decision === "decline") return "Deny"; + if (decision === "cancel") return "Cancel"; + return "Choose"; +} + +function commandDecisionKey(decision: CommandApprovalDecision): string { + if (typeof decision === "string") return decision; + if ("acceptWithExecpolicyAmendment" in decision) return "acceptWithExecpolicyAmendment"; + if ("applyNetworkPolicyAmendment" in decision) { + const amendment = decision.applyNetworkPolicyAmendment.network_policy_amendment; + const host = amendment.host; + return `applyNetworkPolicyAmendment:${amendment.action}:${typeof host === "string" ? host : ""}`; + } + return "unknown"; +} + +function approvalSummary(reason: unknown, target: unknown, fallback: string): string { + const lines = [nonEmptyString(reason), nonEmptyString(target)].filter((value): value is string => Boolean(value)); + return (lines.length > 0 ? lines : [fallback]).join("\n"); +} + +function approvalResultSummary(reason: unknown, target: unknown, fallback: string): string { + return nonEmptyString(reason) ?? nonEmptyString(target) ?? fallback; +} + +function commandActionRows(value: unknown, cwd: string | null | undefined): ApprovalDetailRow[] { + if (!Array.isArray(value) || value.length === 0) return []; + return [ + { + key: value.length === 1 ? "action" : "actions", + value: value + .map((action) => (isCommandAction(action) ? commandActionLabel(action, cwd) : stringValue(action, "unknown action"))) + .join("\n"), + }, + ]; +} + +function commandActionLabel(action: CommandAction, cwd: string | null | undefined): string { + if (action.type === "read") { + const path = pathLabel(action.path, cwd); + return path ? `read ${path}` : `read ${nonEmptyString(action.name) ?? "file"}`; + } + if (action.type === "search") { + const query = nonEmptyString(action.query); + const path = pathLabel(action.path, cwd); + if (query && path) return `search "${query}" in ${path}`; + if (query) return `search "${query}"`; + if (path) return `search ${path}`; + return "search"; + } + if (action.type === "listFiles") { + return `list files ${pathLabel(action.path, cwd) ?? "workspace"}`; + } + return nonEmptyString(action.command) ?? action.type; +} + +function isCommandAction(value: unknown): value is CommandAction { + return value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"; +} + +function pathLabel(path: unknown, cwd: string | null | undefined): string | null { + const value = nonEmptyString(path); + return value ? pathRelativeToRoot(value, cwd) : null; +} + +function networkApprovalContextLabel(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const context = value as NetworkApprovalContext; + const host = nonEmptyString(context.host); + if (!host) return null; + const protocol = nonEmptyString(context.protocol); + return protocol ? `${protocol}://${host}` : host; +} + +function prefixedPermissionRows(prefix: string, permissions: unknown): ApprovalDetailRow[] { + if (!permissions) return []; + return permissionRows(permissions).map((row) => ({ ...row, key: `${prefix} ${row.key}` })); +} + +function permissionRows(permissions: unknown): ApprovalDetailRow[] { + const profile = asRecordOrNull(permissions); + if (!profile) return []; + const rows: ApprovalDetailRow[] = []; + const networkEnabled = asRecordOrNull(profile["network"])?.["enabled"]; + if (typeof networkEnabled === "boolean") { + rows.push({ key: "network", value: networkEnabled ? "enabled" : "disabled" }); + } + + const fileSystem = asRecordOrNull(profile["fileSystem"]); + if (!fileSystem) return rows; + + const entries = fileSystem["entries"]; + if (Array.isArray(entries) && entries.length > 0) { + rows.push({ + key: "filesystem", + value: entries + .map((entry) => { + const record = asRecordOrNull(entry); + return record ? `${fileSystemPathLabel(record["path"])} (${stringValue(record["access"], "unknown")})` : stringValue(entry); + }) + .join("\n"), + }); + } + addOptional(rows, "read", fileSystem["read"]); + addOptional(rows, "write", fileSystem["write"]); + addOptional(rows, "glob depth", fileSystem["globScanMaxDepth"]); + return rows; +} + +function fileSystemPathLabel(path: unknown): string { + const record = asRecordOrNull(path); + if (!record) return stringValue(path, "unknown"); + if (record["type"] === "path") return stringValue(record["path"], "unknown"); + if (record["type"] === "glob_pattern") return stringValue(record["pattern"], "unknown"); + + const special = asRecordOrNull(record["value"]); + if (!special) return stringValue(path, "unknown"); + if (special["kind"] === "project_roots") { + const subpath = nonEmptyString(special["subpath"]); + return subpath ? `project_roots/${subpath}` : "project_roots"; + } + if (special["kind"] === "unknown") { + const specialPath = nonEmptyString(special["path"]) ?? "unknown"; + const subpath = nonEmptyString(special["subpath"]); + return subpath ? `${specialPath}/${subpath}` : specialPath; + } + return nonEmptyString(special["kind"]) ?? "unknown"; +} + +function networkPolicyAmendmentsLabel(value: unknown): string | null { + if (!Array.isArray(value) || value.length === 0) return null; + return value.map(networkPolicyAmendmentLabel).join("\n"); +} + +function networkPolicyAmendmentLabel(value: unknown): string { + if (!value || typeof value !== "object") return stringValue(value, "rule"); + const amendment = value as { action?: unknown; host?: unknown }; + return `${nonEmptyString(amendment.action) ?? "rule"} ${nonEmptyString(amendment.host) ?? "(unknown host)"}`; +} + +function addOptional(rows: ApprovalDetailRow[], key: string, value: unknown): void { + if (value === null || value === undefined) return; + if (Array.isArray(value) && value.length === 0) return; + rows.push({ key, value: stringValue(value) }); +} + +function stringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); + if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean")) { + return value.join("\n"); + } + if (value === null || value === undefined) return fallback; + return jsonPreview(value); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function asRecordOrNull(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + function mcpElicitationFieldsFromSchema( properties: Record, required: ReadonlySet, diff --git a/src/domain/pending-requests/model.ts b/src/domain/pending-requests/model.ts index 20f64391..9c27fbe7 100644 --- a/src/domain/pending-requests/model.ts +++ b/src/domain/pending-requests/model.ts @@ -1,83 +1,48 @@ export type PendingRequestId = string | number; -type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel"; +export type ApprovalActionIntent = "accept" | "accept-session" | "decline" | "cancel"; -export type CommandApprovalDecision = - | SimpleApprovalDecision - | { acceptWithExecpolicyAmendment: unknown } - | { applyNetworkPolicyAmendment: { network_policy_amendment: { action: "allow" | "deny"; [key: string]: unknown } } }; +type ApprovalKind = "command" | "fileChange" | "permission"; -interface CommandApprovalParams { - threadId: string; - turnId: string; - itemId: string; - startedAtMs: number; - approvalId?: string | null; - reason?: string | null; - networkApprovalContext?: unknown; - command?: string | null; - cwd?: string | null; - commandActions?: unknown[] | null; - additionalPermissions?: unknown; - proposedExecpolicyAmendment?: unknown; - proposedNetworkPolicyAmendments?: unknown[] | null; - availableDecisions?: CommandApprovalDecision[] | null; +export interface ApprovalDetailRow { + key: string; + value: string; } -interface FileChangeApprovalParams { - threadId: string; - turnId: string; - itemId: string; - startedAtMs: number; - reason: string | null; - grantRoot: string | null; +interface ApprovalResponseOptions { + accept: unknown; + acceptSession: unknown; + decline: unknown; + cancel: unknown; } -interface PermissionProfile { - network?: { enabled?: boolean | null } | null; - fileSystem?: { - entries?: readonly { path: unknown; access?: unknown }[] | null; - read?: unknown; - write?: unknown; - globScanMaxDepth?: unknown; - } | null; +interface ApprovalOptionAction { + kind: "approval-option"; + intent: ApprovalActionIntent; + response: unknown; } -interface PermissionsApprovalParams { - threadId: string; - turnId: string; - itemId: string; - startedAtMs: number; - reason: string | null; - cwd: string; - environmentId: string | null; - permissions: PermissionProfile; +export type ApprovalAction = ApprovalActionIntent | ApprovalOptionAction; + +export interface PendingApprovalOption { + id: string; + label: string; + action: ApprovalAction; + intent: ApprovalActionIntent; } -export type ApprovalAction = "accept" | "accept-session" | "decline" | "cancel" | CommandApprovalDecisionAction; - -interface CommandApprovalDecisionAction { - kind: "command-decision"; - decision: CommandApprovalDecision; +export interface PendingApproval { + requestId: PendingRequestId; + kind: ApprovalKind; + turnId: string | null; + title: string; + summary: string; + resultSummary: string; + details: readonly ApprovalDetailRow[]; + responses: ApprovalResponseOptions; + actionOptions: readonly PendingApprovalOption[] | null; } -export type PendingApproval = - | { - requestId: PendingRequestId; - method: "item/commandExecution/requestApproval"; - params: CommandApprovalParams; - } - | { - requestId: PendingRequestId; - method: "item/fileChange/requestApproval"; - params: FileChangeApprovalParams; - } - | { - requestId: PendingRequestId; - method: "item/permissions/requestApproval"; - params: PermissionsApprovalParams; - }; - interface PendingUserInputOption { label: string; description: string; @@ -102,7 +67,6 @@ interface PendingUserInputParams { export interface PendingUserInput { requestId: PendingRequestId; - method: "item/tool/requestUserInput"; params: PendingUserInputParams; } @@ -176,30 +140,11 @@ interface PendingMcpElicitationUrlParams { export interface PendingMcpElicitation { requestId: PendingRequestId; - method: "mcpServer/elicitation/request"; params: PendingMcpElicitationFormParams | PendingMcpElicitationUrlParams; } export function approvalActionKind(action: ApprovalAction): "accept" | "accept-session" | "decline" | "cancel" { - if (!isCommandDecisionAction(action)) return action; - const decision = action.decision; - if (typeof decision === "string") return simpleApprovalActionKind(decision); - if ("acceptWithExecpolicyAmendment" in decision) return "accept-session"; - if ("applyNetworkPolicyAmendment" in decision) { - return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "accept-session" : "decline"; - } - return "decline"; -} - -function simpleApprovalActionKind(decision: string): "accept" | "accept-session" | "decline" | "cancel" { - if (decision === "accept") return "accept"; - if (decision === "acceptForSession") return "accept-session"; - if (decision === "cancel") return "cancel"; - return "decline"; -} - -function isCommandDecisionAction(action: ApprovalAction): action is CommandApprovalDecisionAction { - return typeof action === "object"; + return typeof action === "object" ? action.intent : action; } export function questionDefaultAnswer(question: PendingUserInputQuestion): string { diff --git a/src/features/chat/domain/pending-requests/approval.ts b/src/features/chat/domain/pending-requests/approval.ts index cd0d8daa..e10bd851 100644 --- a/src/features/chat/domain/pending-requests/approval.ts +++ b/src/features/chat/domain/pending-requests/approval.ts @@ -1,187 +1,17 @@ -import { jsonPreview } from "../../../../utils"; -import { pathRelativeToRoot } from "../message-stream/format/path-labels"; -import { permissionRows, type MessageStreamPermissionProfile } from "../message-stream/format/permission-rows"; -import type { PendingApproval } from "../../../../domain/pending-requests/model"; - -interface ApprovalSummaryParts { - reason: string | null; - target: string | null; - fallback: string; - lines: string[]; -} - -interface DetailRow { - key: string; - value: string; -} - -interface CommandAction { - type: string; - command?: unknown; - name?: unknown; - path?: unknown; - query?: unknown; -} - -interface NetworkApprovalContext { - host?: unknown; - protocol?: unknown; -} +import type { ApprovalDetailRow, PendingApproval } from "../../../../domain/pending-requests/model"; export function approvalTitle(approval: PendingApproval): string { - switch (approval.method) { - case "item/commandExecution/requestApproval": - return "Command approval"; - case "item/fileChange/requestApproval": - return "File change approval"; - case "item/permissions/requestApproval": - return "Permission approval"; - } + return approval.title; } export function approvalSummary(approval: PendingApproval): string { - return approvalSummaryParts(approval).lines.join("\n"); + return approval.summary; } export function approvalResultSummary(approval: PendingApproval): string { - const summary = approvalSummaryParts(approval); - return summary.reason ?? summary.target ?? summary.fallback; + return approval.resultSummary; } -export function approvalDetails(approval: PendingApproval): DetailRow[] { - const rows: DetailRow[] = []; - addOptional(rows, "reason", approval.params.reason); - switch (approval.method) { - case "item/commandExecution/requestApproval": - addOptional(rows, "command", approval.params.command); - addOptional(rows, "cwd", approval.params.cwd); - addOptional(rows, "network", networkApprovalContextLabel(approval.params.networkApprovalContext)); - rows.push(...commandActionRows(approval.params.commandActions, approval.params.cwd)); - rows.push(...prefixedPermissionRows("additional", approval.params.additionalPermissions)); - addOptional(rows, "future command rule", approval.params.proposedExecpolicyAmendment); - addOptional(rows, "future network rules", networkPolicyAmendmentsLabel(approval.params.proposedNetworkPolicyAmendments)); - break; - case "item/fileChange/requestApproval": - addOptional(rows, "grant root", approval.params.grantRoot); - break; - case "item/permissions/requestApproval": - addOptional(rows, "cwd", approval.params.cwd); - addOptional(rows, "environment", approval.params.environmentId); - rows.push(...permissionRows(approval.params.permissions as MessageStreamPermissionProfile)); - break; - } - return rows; -} - -function approvalSummaryParts(approval: PendingApproval): ApprovalSummaryParts { - switch (approval.method) { - case "item/commandExecution/requestApproval": { - const reason = nonEmptyString(approval.params.reason); - const target = nonEmptyString(approval.params.command); - return summaryParts(reason, target, "Command execution requested."); - } - case "item/fileChange/requestApproval": { - const reason = nonEmptyString(approval.params.reason); - const grantRoot = nonEmptyString(approval.params.grantRoot); - return summaryParts(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"); - } - case "item/permissions/requestApproval": { - return summaryParts(approval.params.reason, `cwd: ${approval.params.cwd}`, "Permission change requested."); - } - } -} - -function summaryParts(reason: string | null, target: string | null, fallback: string, extra?: string | null): ApprovalSummaryParts { - const lines = [reason, target, extra].filter((value): value is string => Boolean(value)); - return { - reason, - target, - fallback, - lines: lines.length > 0 ? lines : [fallback], - }; -} - -function commandActionRows(value: unknown, cwd: string | null | undefined): DetailRow[] { - if (!Array.isArray(value) || value.length === 0) return []; - return [ - { - key: value.length === 1 ? "action" : "actions", - value: value - .map((action) => (isCommandAction(action) ? commandActionLabel(action, cwd) : stringValue(action, "unknown action"))) - .join("\n"), - }, - ]; -} - -function commandActionLabel(action: CommandAction, cwd: string | null | undefined): string { - if (action.type === "read") { - const path = pathLabel(action.path, cwd); - return path ? `read ${path}` : `read ${nonEmptyString(action.name) ?? "file"}`; - } - if (action.type === "search") { - const query = nonEmptyString(action.query); - const path = pathLabel(action.path, cwd); - if (query && path) return `search "${query}" in ${path}`; - if (query) return `search "${query}"`; - if (path) return `search ${path}`; - return "search"; - } - if (action.type === "listFiles") { - return `list files ${pathLabel(action.path, cwd) ?? "workspace"}`; - } - return nonEmptyString(action.command) ?? action.type; -} - -function isCommandAction(value: unknown): value is CommandAction { - return value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"; -} - -function pathLabel(path: unknown, cwd: string | null | undefined): string | null { - const value = nonEmptyString(path); - return value ? pathRelativeToRoot(value, cwd) : null; -} - -function networkApprovalContextLabel(value: unknown): string | null { - if (!value || typeof value !== "object") return null; - const context = value as NetworkApprovalContext; - const host = nonEmptyString(context.host); - if (!host) return null; - const protocol = nonEmptyString(context.protocol); - return protocol ? `${protocol}://${host}` : host; -} - -function prefixedPermissionRows(prefix: string, permissions: unknown): DetailRow[] { - if (!permissions) return []; - return permissionRows(permissions as MessageStreamPermissionProfile).map((row) => ({ ...row, key: `${prefix} ${row.key}` })); -} - -function networkPolicyAmendmentsLabel(value: unknown): string | null { - if (!Array.isArray(value) || value.length === 0) return null; - return value.map(networkPolicyAmendmentLabel).join("\n"); -} - -function networkPolicyAmendmentLabel(value: unknown): string { - if (!value || typeof value !== "object") return stringValue(value, "rule"); - const amendment = value as { action?: unknown; host?: unknown }; - return `${nonEmptyString(amendment.action) ?? "rule"} ${nonEmptyString(amendment.host) ?? "(unknown host)"}`; -} - -function addOptional(rows: DetailRow[], key: string, value: unknown): void { - if (value === null || value === undefined) return; - if (Array.isArray(value) && value.length === 0) return; - rows.push({ key, value: stringValue(value) }); -} - -function stringValue(value: unknown, fallback = ""): string { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); - if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean")) { - return value.join("\n"); - } - if (value === null || value === undefined) return fallback; - return jsonPreview(value); -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +export function approvalDetails(approval: PendingApproval): ApprovalDetailRow[] { + return [...approval.details]; } diff --git a/src/features/chat/domain/pending-requests/result-items.ts b/src/features/chat/domain/pending-requests/result-items.ts index ccdbc5c1..35361b9a 100644 --- a/src/features/chat/domain/pending-requests/result-items.ts +++ b/src/features/chat/domain/pending-requests/result-items.ts @@ -18,7 +18,7 @@ export function createApprovalResultItem(approval: PendingApproval, action: Appr kind: "approvalResult", role: "tool", text: approvalResultText(approval, action), - ...definedProp("turnId", approvalTurnId(approval)), + ...definedProp("turnId", approval.turnId ?? undefined), provenance: { source: "localUser", channel: "response", interaction: "approvalResponse", sourceId: String(approval.requestId) }, executionState: kind === "accept" || kind === "accept-session" ? "completed" : "failed", approval: { @@ -91,11 +91,6 @@ function approvalResultStatus(kind: ReturnType): stri return "denied"; } -function approvalTurnId(approval: PendingApproval): string | undefined { - const params = approval.params as { turnId?: unknown }; - return typeof params.turnId === "string" ? params.turnId : undefined; -} - function mcpElicitationResultText(elicitation: PendingMcpElicitation, action: McpElicitationAction): string { const label = `MCP request from ${elicitation.params.serverName}`; if (action === "accept") return `${label} accepted.`; diff --git a/src/features/chat/domain/pending-requests/signatures.ts b/src/features/chat/domain/pending-requests/signatures.ts index 30ce8a43..6d255844 100644 --- a/src/features/chat/domain/pending-requests/signatures.ts +++ b/src/features/chat/domain/pending-requests/signatures.ts @@ -14,7 +14,7 @@ export function pendingRequestsSignature( return ""; } return JSON.stringify({ - approvals: approvals.map((approval) => ({ id: approval.requestId, method: approval.method })), + approvals: approvals.map((approval) => ({ id: approval.requestId, kind: approval.kind })), inputs: inputs.map((input) => ({ id: input.requestId, questions: input.params.questions.map((question) => ({ @@ -55,8 +55,8 @@ export function pendingRequestFocusSignature( return ""; } return JSON.stringify({ - approvals: approvals.map((approval) => ({ id: approval.requestId, method: approval.method })), - inputs: inputs.map((input) => ({ id: input.requestId, method: input.method })), - mcpElicitations: mcpElicitations.map((elicitation) => ({ id: elicitation.requestId, method: elicitation.method })), + approvals: approvals.map((approval) => ({ id: approval.requestId, kind: approval.kind })), + inputs: inputs.map((input) => ({ id: input.requestId })), + mcpElicitations: mcpElicitations.map((elicitation) => ({ id: elicitation.requestId, mode: elicitation.params.mode })), }); } diff --git a/src/features/chat/presentation/pending-requests/approval-view.ts b/src/features/chat/presentation/pending-requests/approval-view.ts index 04ad8953..af1a5aa9 100644 --- a/src/features/chat/presentation/pending-requests/approval-view.ts +++ b/src/features/chat/presentation/pending-requests/approval-view.ts @@ -1,9 +1,4 @@ -import { - approvalActionKind, - type ApprovalAction, - type CommandApprovalDecision, - type PendingApproval, -} from "../../../../domain/pending-requests/model"; +import { approvalActionKind, type ApprovalAction, type PendingApproval } from "../../../../domain/pending-requests/model"; export interface ApprovalActionOption { id: string; @@ -13,14 +8,13 @@ export interface ApprovalActionOption { } export function approvalActionOptions(approval: PendingApproval): ApprovalActionOption[] { - if (approval.method !== "item/commandExecution/requestApproval") return defaultApprovalActionOptions(); - const decisions = approval.params.availableDecisions; - if (!decisions || decisions.length === 0) return defaultApprovalActionOptions(); - return decisions.map((decision, index) => ({ - id: `command-decision:${String(index)}:${commandDecisionKey(decision)}`, - label: commandDecisionLabel(decision), - action: { kind: "command-decision", decision }, - className: commandDecisionClassName(decision), + const options = approval.actionOptions; + if (!options || options.length === 0) return defaultApprovalActionOptions(); + return options.map((option) => ({ + id: option.id, + label: option.label, + action: option.action, + className: approvalActionClassName(option.action), })); } @@ -33,36 +27,8 @@ function defaultApprovalActionOptions(): ApprovalActionOption[] { ]; } -function commandDecisionLabel(decision: CommandApprovalDecision): string { - if (typeof decision === "string") return simpleCommandDecisionLabel(decision); - if ("acceptWithExecpolicyAmendment" in decision) return "Allow rule"; - if ("applyNetworkPolicyAmendment" in decision) { - return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "Allow network rule" : "Deny network rule"; - } - return "Choose"; -} - -function simpleCommandDecisionLabel(decision: string): string { - if (decision === "accept") return "Allow"; - if (decision === "acceptForSession") return "Allow session"; - if (decision === "decline") return "Deny"; - if (decision === "cancel") return "Cancel"; - return "Choose"; -} - -function commandDecisionKey(decision: CommandApprovalDecision): string { - if (typeof decision === "string") return decision; - if ("acceptWithExecpolicyAmendment" in decision) return "acceptWithExecpolicyAmendment"; - if ("applyNetworkPolicyAmendment" in decision) { - const amendment = decision.applyNetworkPolicyAmendment.network_policy_amendment; - const host = amendment["host"]; - return `applyNetworkPolicyAmendment:${amendment.action}:${typeof host === "string" ? host : ""}`; - } - return "unknown"; -} - -function commandDecisionClassName(decision: CommandApprovalDecision): string { - const kind = approvalActionKind({ kind: "command-decision", decision }); +function approvalActionClassName(action: ApprovalAction): string { + const kind = approvalActionKind(action); if (kind === "accept") return "mod-cta"; if (kind === "decline") return "mod-warning"; return ""; diff --git a/tests/app-server/server-requests.test.ts b/tests/app-server/server-requests.test.ts index 445b5e3e..c74f1740 100644 --- a/tests/app-server/server-requests.test.ts +++ b/tests/app-server/server-requests.test.ts @@ -34,7 +34,7 @@ describe("app-server server request protocol adapters", () => { const approval = expectPresent(appServerApprovalRequest(request)); - expect(approval.method).toBe("item/permissions/requestApproval"); + expect(approval.kind).toBe("permission"); expect(appServerApprovalResponse(approval, "decline")).toEqual({ permissions: {}, scope: "turn" }); expect(appServerApprovalResponse(approval, "accept")).toEqual({ permissions: { network: { enabled: true } }, @@ -77,7 +77,6 @@ describe("app-server server request protocol adapters", () => { expect(input).toMatchObject({ requestId: 42, - method: "mcpServer/elicitation/request", params: { mode: "form", threadId: "thread", diff --git a/tests/features/chat/conversation/pending-requests/state.test.ts b/tests/features/chat/conversation/pending-requests/state.test.ts index 6dfb34d2..c8cffe04 100644 --- a/tests/features/chat/conversation/pending-requests/state.test.ts +++ b/tests/features/chat/conversation/pending-requests/state.test.ts @@ -18,16 +18,14 @@ describe("chat pending request state", () => { approvals: [ { requestId: 1, - method: "item/commandExecution/requestApproval", - params: { - threadId: "thread", - turnId: "turn", - itemId: "command", - command: "pwd", - cwd: "/tmp", - reason: null, - startedAtMs: 1, - }, + kind: "command", + turnId: "turn", + title: "Command approval", + summary: "pwd", + resultSummary: "pwd", + details: [{ key: "command", value: "pwd" }], + responses: { accept: {}, acceptSession: {}, decline: {}, cancel: {} }, + actionOptions: null, }, ], pendingUserInputs: [], diff --git a/tests/features/chat/protocol/inbound/handler.test.ts b/tests/features/chat/protocol/inbound/handler.test.ts index bd6ae8cc..be3fadd0 100644 --- a/tests/features/chat/protocol/inbound/handler.test.ts +++ b/tests/features/chat/protocol/inbound/handler.test.ts @@ -14,6 +14,7 @@ import { } from "../../../../../src/features/chat/application/state/root-reducer"; import type { ChatStateStore } from "../../../../../src/features/chat/application/state/store"; import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages"; +import { appServerApprovalRequest, appServerUserInputRequest } from "../../../../../src/app-server/protocol/server-requests"; import type { Thread as PanelThread } from "../../../../../src/domain/threads/model"; import type { TurnRecord } from "../../../../../src/app-server/protocol/turn"; import { createLocalIdSource } from "../../../../../src/shared/id/local-id"; @@ -70,6 +71,14 @@ function expectPresent(value: T | null | undefined): T { return value; } +function pendingApprovalFromRequest(request: ServerRequest) { + return expectPresent(appServerApprovalRequest(request)); +} + +function pendingUserInputFromRequest(request: ServerRequest) { + return expectPresent(appServerUserInputRequest(request)); +} + describe("ChatInboundHandler", () => { describe("active turn routing", () => { it("applies matching streaming deltas as assistant markdown", () => { @@ -1101,22 +1110,22 @@ describe("ChatInboundHandler", () => { state = chatStateWith(state, { requests: { approvals: [ - { - requestId: 50, + pendingApprovalFromRequest({ + id: 50, method: "item/commandExecution/requestApproval", params: { ...expectPresent(supportedApprovalRequests()[0]).params, threadId: "thread-active", } as Extract["params"], - }, + }), ], }, }); state = chatStateWith(state, { requests: { pendingUserInputs: [ - { - requestId: 50, + pendingUserInputFromRequest({ + id: 50, method: "item/tool/requestUserInput", params: { threadId: "thread-active", @@ -1125,7 +1134,7 @@ describe("ChatInboundHandler", () => { questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }], autoResolutionMs: null, }, - }, + }), ], }, }); @@ -1134,7 +1143,6 @@ describe("ChatInboundHandler", () => { pendingMcpElicitations: [ { requestId: 50, - method: "mcpServer/elicitation/request", params: { threadId: "thread-active", turnId: null, @@ -1224,22 +1232,22 @@ describe("ChatInboundHandler", () => { state = chatStateWith(state, { requests: { approvals: [ - { - requestId: 10, + pendingApprovalFromRequest({ + id: 10, method: "item/commandExecution/requestApproval", params: expectPresent(supportedApprovalRequests()[0]).params as Extract< ServerRequest, { method: "item/commandExecution/requestApproval" } >["params"], - }, + }), ], }, }); state = chatStateWith(state, { requests: { pendingUserInputs: [ - { - requestId: 20, + pendingUserInputFromRequest({ + id: 20, method: "item/tool/requestUserInput", params: { threadId: "thread-active", @@ -1248,7 +1256,7 @@ describe("ChatInboundHandler", () => { questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }], autoResolutionMs: null, }, - }, + }), ], }, }); diff --git a/tests/features/chat/protocol/server-requests/approval.test.ts b/tests/features/chat/protocol/server-requests/approval.test.ts index ded0c1c3..7a0da7b0 100644 --- a/tests/features/chat/protocol/server-requests/approval.test.ts +++ b/tests/features/chat/protocol/server-requests/approval.test.ts @@ -6,7 +6,6 @@ import { } from "../../../../../src/app-server/protocol/server-requests"; import { approvalDetails, approvalSummary, approvalTitle } from "../../../../../src/features/chat/domain/pending-requests/approval"; import { createApprovalResultItem } from "../../../../../src/features/chat/domain/pending-requests/result-items"; -import type { CommandApprovalDecision } from "../../../../../src/domain/pending-requests/model"; import { approvalActionOptions } from "../../../../../src/features/chat/presentation/pending-requests/approval-view"; import type { ServerRequest } from "../../../../../src/app-server/connection/rpc-messages"; @@ -80,13 +79,7 @@ describe("approval model", () => { } as unknown as ServerRequest), ); - expect(approval).toMatchObject({ - method: "item/fileChange/requestApproval", - params: { - reason: null, - grantRoot: null, - }, - }); + expect(approval).toMatchObject({ kind: "fileChange", title: "File change approval" }); expect(approvalSummary(approval)).toBe("Allow file changes?"); expect(approvalDetails(approval)).toEqual([]); }); @@ -94,10 +87,10 @@ describe("approval model", () => { it("uses command approval decisions supplied by app-server", () => { const allowRegistryDecision = { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } }, - } satisfies CommandApprovalDecision; + } as const; const allowApiDecision = { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "api.github.com", action: "allow" } }, - } satisfies CommandApprovalDecision; + } as const; const request: ServerRequest = { id: 30, method: "item/commandExecution/requestApproval", @@ -134,7 +127,7 @@ describe("approval model", () => { }); it("keeps future simple command decisions renderable", () => { - const futureDecision = "restartWithNetwork" as CommandApprovalDecision; + const futureDecision = "restartWithNetwork"; const request = { id: 32, method: "item/commandExecution/requestApproval", @@ -157,9 +150,9 @@ describe("approval model", () => { expect(options).toEqual([ { - id: "command-decision:0:restartWithNetwork", + id: "approval-option:0:restartWithNetwork", label: "Choose", - action: { kind: "command-decision", decision: futureDecision }, + action: { kind: "approval-option", intent: "decline", response: { decision: futureDecision } }, className: "mod-warning", }, ]); @@ -415,7 +408,7 @@ describe("approval model", () => { for (const { request, acceptSession, cancel } of requests) { const approval = expectPresent(toPendingApproval(request)); - if (approval.method === "item/commandExecution/requestApproval") { + if (approval.kind === "command") { const options = approvalActionOptions(approval); expect(approvalResponse(approval, expectPresent(options[0]).action)).toEqual(acceptSession); expect(approvalResponse(approval, expectPresent(options[1]).action)).toEqual(cancel); diff --git a/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts b/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts index 4432f782..948301db 100644 --- a/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts +++ b/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts @@ -18,7 +18,6 @@ describe("MCP elicitation request model", () => { expect(input).toMatchObject({ requestId: 42, - method: "mcpServer/elicitation/request", params: { mode: "form", threadId: "thread", diff --git a/tests/features/chat/protocol/server-requests/user-input.test.ts b/tests/features/chat/protocol/server-requests/user-input.test.ts index f6562c9a..0a62ac5d 100644 --- a/tests/features/chat/protocol/server-requests/user-input.test.ts +++ b/tests/features/chat/protocol/server-requests/user-input.test.ts @@ -40,7 +40,7 @@ describe("user input model", () => { }; const input = expectPresent(toPendingUserInput(request)); - expect(input).toMatchObject({ requestId: 7, method: "item/tool/requestUserInput" }); + expect(input).toMatchObject({ requestId: 7 }); expect(questionDefaultAnswer(expectPresent(request.params.questions[0]))).toBe("Recommended"); expect(answersForPendingUserInput(input, new Map())).toEqual({ direction: "Recommended" }); expect(answersForPendingUserInput(input, new Map([["7:direction", "Left"]]))).toEqual({ direction: "Left" }); @@ -82,7 +82,7 @@ describe("user input model", () => { expect(pendingRequestFocusSignature([], [input], [])).toBe( JSON.stringify({ approvals: [], - inputs: [{ id: 7, method: "item/tool/requestUserInput" }], + inputs: [{ id: 7 }], mcpElicitations: [], }), ); diff --git a/tests/features/chat/state-reducer.test.ts b/tests/features/chat/state-reducer.test.ts index 8ed89721..1958f904 100644 --- a/tests/features/chat/state-reducer.test.ts +++ b/tests/features/chat/state-reducer.test.ts @@ -762,15 +762,24 @@ function suggestion(display: string): ChatState["composer"]["suggestions"][numbe function approval(requestId: number): ChatState["requests"]["approvals"][number] { return { requestId, - method: "item/commandExecution/requestApproval", - params: { threadId: "thread", turnId: "turn", itemId: "item", command: "pwd", cwd: "/tmp", reason: "Need access", startedAtMs: 1 }, + kind: "command", + turnId: "turn", + title: "Command approval", + summary: "Need access\npwd", + resultSummary: "Need access", + details: [ + { key: "reason", value: "Need access" }, + { key: "command", value: "pwd" }, + { key: "cwd", value: "/tmp" }, + ], + responses: { accept: {}, acceptSession: {}, decline: {}, cancel: {} }, + actionOptions: null, }; } function userInput(requestId: number): ChatState["requests"]["pendingUserInputs"][number] { return { requestId, - method: "item/tool/requestUserInput", params: { threadId: "thread", turnId: "turn", diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index 28d05797..46d652c8 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -308,26 +308,36 @@ describe("pending request renderer decisions", () => { it("renders command approval buttons from app-server available decisions", () => { const parent = document.createElement("div"); + const allowResponse = { + decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, + }; + const denyResponse = { decision: "decline" }; const approval: PendingApproval = { requestId: 43, - method: "item/commandExecution/requestApproval", - params: { - threadId: "thread", - turnId: "turn", - itemId: "command", - startedAtMs: 1, - reason: "Needs network", - networkApprovalContext: { host: "registry.npmjs.org", protocol: "https" }, - command: null, - cwd: "/vault", - commandActions: [], - proposedExecpolicyAmendment: null, - proposedNetworkPolicyAmendments: [], - availableDecisions: [ - { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, - "decline", - ], - }, + kind: "command", + turnId: "turn", + title: "Command approval", + summary: "Needs network", + resultSummary: "Needs network", + details: [ + { key: "reason", value: "Needs network" }, + { key: "network", value: "https://registry.npmjs.org" }, + ], + responses: { accept: {}, acceptSession: {}, decline: denyResponse, cancel: {} }, + actionOptions: [ + { + id: "approval-option:0:network-allow", + label: "Allow network rule", + intent: "accept-session", + action: { kind: "approval-option", intent: "accept-session", response: allowResponse }, + }, + { + id: "approval-option:1:decline", + label: "Deny", + intent: "decline", + action: { kind: "approval-option", intent: "decline", response: denyResponse }, + }, + ], }; const resolveApproval = vi.fn(); @@ -352,8 +362,9 @@ describe("pending request renderer decisions", () => { allowButton.click(); }); expect(resolveApproval).toHaveBeenCalledWith(approval.requestId, { - kind: "command-decision", - decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } }, + kind: "approval-option", + intent: "accept-session", + response: allowResponse, }); }); @@ -776,7 +787,6 @@ function pendingMcpElicitation({ } = {}): PendingMcpElicitation { return { requestId, - method: "mcpServer/elicitation/request", params: { threadId: "thread", turnId: null, @@ -804,7 +814,6 @@ function pendingMcpElicitation({ function pendingMcpMultiSelectElicitation(): ReturnType { return pendingMcpElicitationViewModel({ requestId: 53, - method: "mcpServer/elicitation/request", params: { threadId: "thread", turnId: null, diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index bbcc2ce5..d5afb967 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -294,7 +294,6 @@ export function withMessageContentScrollHeight(scrollHeight: number, fn: () = export function pendingUserInput(): PendingUserInput { return { requestId: 99, - method: "item/tool/requestUserInput", params: { threadId: "thread", turnId: "turn", @@ -350,16 +349,22 @@ export function pendingFreeformUserInput(): PendingUserInput { export function pendingApproval(): PendingApproval { return { requestId: 42, - method: "item/permissions/requestApproval", - params: { - threadId: "thread", - turnId: "turn", - itemId: "permission", - environmentId: null, - startedAtMs: 1, - cwd: "/vault", - reason: "Need network", - permissions: { network: { enabled: true }, fileSystem: null }, + kind: "permission", + turnId: "turn", + title: "Permission approval", + summary: "Need network\ncwd: /vault", + resultSummary: "Need network", + details: [ + { key: "reason", value: "Need network" }, + { key: "cwd", value: "/vault" }, + { key: "network", value: "enabled" }, + ], + responses: { + accept: { permissions: { network: { enabled: true } }, scope: "turn" }, + acceptSession: { permissions: { network: { enabled: true } }, scope: "session" }, + decline: { permissions: {}, scope: "turn" }, + cancel: { permissions: {}, scope: "turn" }, }, + actionOptions: null, }; }