Coarsen pending approval models

This commit is contained in:
murashit 2026-06-22 20:39:41 +09:00
parent 2ff5c89e2b
commit b6629a75db
15 changed files with 483 additions and 443 deletions

View file

@ -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<Method extends ServerRequest["method"]> = Extract<ServerRequest, { method: Method }>;
@ -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<CommandApprovalParams["availableDecisions"]>[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<ApprovalAction, { kind: "command-decision" }> {
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<ApprovalAction, { kind: "approval-option" }> {
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<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function mcpElicitationFieldsFromSchema(
properties: Record<string, AppServerMcpElicitationPrimitiveSchema | undefined>,
required: ReadonlySet<string>,

View file

@ -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 {

View file

@ -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];
}

View file

@ -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<typeof approvalActionKind>): 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.`;

View file

@ -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 })),
});
}

View file

@ -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 "";

View file

@ -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",

View file

@ -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: [],

View file

@ -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<T>(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<ServerRequest, { method: "item/commandExecution/requestApproval" }>["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,
},
},
}),
],
},
});

View file

@ -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);

View file

@ -18,7 +18,6 @@ describe("MCP elicitation request model", () => {
expect(input).toMatchObject({
requestId: 42,
method: "mcpServer/elicitation/request",
params: {
mode: "form",
threadId: "thread",

View file

@ -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: [],
}),
);

View file

@ -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",

View file

@ -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<typeof pendingMcpElicitationViewModel> {
return pendingMcpElicitationViewModel({
requestId: 53,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: null,

View file

@ -294,7 +294,6 @@ export function withMessageContentScrollHeight<T>(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,
};
}