v2.3.1-permission-questions: enrich ACP permission wire for interactive questions and elicitations
The permission_requested WS frame now carries kind ('tool'|'question'|'plan'|
'elicitation'), input (the tool's rawInput payload), and description fields.
PermissionCard detects question-type permissions (Claude Code's AskUserQuestion)
and renders an interactive radio/checkbox form instead of approve/deny buttons.
Submitting answers auto-selects the first allow option.
Also wires up ACP createElicitation (unstable/experimental) — JSON Schema-driven
forms for structured user input. The same PermissionCard renders elicitation
fields with type-appropriate inputs. Both flows use the existing permission-waiter
blocking pattern with 120s timeout.
The response path (POST /api/coder/tasks/:id/permission) now accepts optional
updated_input alongside option_id, forwarded to the ACP agent as the user's
answer payload. Elicitation responses map to accept/decline/cancel actions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Blocks ACP dispatch on permission prompts until the user responds via API.
|
||||
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
|
||||
*/
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
|
||||
import { isUnattendedMode } from './provider-manifest.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface PendingPermission {
|
||||
type: 'permission';
|
||||
request: RequestPermissionRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
@@ -14,11 +15,27 @@ interface PendingPermission {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingByTask = new Map<string, PendingPermission>();
|
||||
interface PendingElicitation {
|
||||
type: 'elicitation';
|
||||
request: CreateElicitationRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: CreateElicitationResponse) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
type PendingEntry = PendingPermission | PendingElicitation;
|
||||
|
||||
const pendingByTask = new Map<string, PendingEntry>();
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
kind: PermissionKind;
|
||||
toolTitle?: string;
|
||||
description?: string;
|
||||
input?: Record<string, unknown>;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
@@ -33,10 +50,25 @@ export function setPermissionHooks(next: PermissionHooks): void {
|
||||
hooks = next;
|
||||
}
|
||||
|
||||
function resolveKind(params: RequestPermissionRequest): PermissionKind {
|
||||
const input = params.toolCall?.rawInput;
|
||||
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
|
||||
return 'question';
|
||||
}
|
||||
return 'tool';
|
||||
}
|
||||
|
||||
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
||||
const kind = resolveKind(params);
|
||||
const rawInput = params.toolCall?.rawInput;
|
||||
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? rawInput as Record<string, unknown>
|
||||
: undefined;
|
||||
return {
|
||||
taskId,
|
||||
kind,
|
||||
toolTitle: params.toolCall?.title ?? undefined,
|
||||
...(input ? { input } : {}),
|
||||
options: params.options.map((o) => ({
|
||||
optionId: o.optionId,
|
||||
label: o.name,
|
||||
@@ -73,24 +105,33 @@ export function waitForPermissionResponse(
|
||||
resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
|
||||
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = toPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function respondToPermission(taskId: string, optionId: string | null): boolean {
|
||||
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return false;
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
if (pending.type === 'elicitation') {
|
||||
if (updatedInput) {
|
||||
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
|
||||
pending.resolve({ action: 'accept', content });
|
||||
} else {
|
||||
pending.resolve({ action: 'decline' });
|
||||
}
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
}
|
||||
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
@@ -100,14 +141,67 @@ export function respondToPermission(taskId: string, optionId: string | null): bo
|
||||
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return null;
|
||||
if (pending.type === 'elicitation') {
|
||||
return elicitationToPrompt(taskId, pending.request);
|
||||
}
|
||||
return toPrompt(taskId, pending.request);
|
||||
}
|
||||
|
||||
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
|
||||
const input: Record<string, unknown> = { message: params.message };
|
||||
if ('requestedSchema' in params) {
|
||||
input.requestedSchema = params.requestedSchema;
|
||||
}
|
||||
return {
|
||||
taskId,
|
||||
kind: 'elicitation',
|
||||
toolTitle: params.message,
|
||||
input,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function waitForElicitationResponse(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
modeId: string | undefined,
|
||||
params: CreateElicitationRequest,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
if (isUnattendedMode(provider, modeId)) {
|
||||
return Promise.resolve({ action: 'decline' });
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = pendingByTask.get(taskId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.reject(new Error('superseded by newer elicitation request'));
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingByTask.delete(taskId);
|
||||
void hooks.onResolved?.(taskId, sessionId);
|
||||
resolve({ action: 'cancel' });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = elicitationToPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelPendingPermission(taskId: string): void {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
if (pending.type === 'elicitation') {
|
||||
pending.resolve({ action: 'cancel' });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user