From 154ef78f7ca50ae5a4798c3436116538b04f939a Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 26 May 2026 21:28:14 +0000 Subject: [PATCH] v2.3.1-permission-questions: enrich ACP permission wire for interactive questions and elicitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/coder/src/index.ts | 2 + apps/coder/src/routes/tasks.ts | 3 +- apps/coder/src/services/acp-dispatch.ts | 10 +- apps/coder/src/services/permission-waiter.ts | 112 +++++- apps/server/src/types/ws-frames.ts | 2 + apps/web/src/api/client.ts | 4 +- apps/web/src/api/types.ts | 4 + apps/web/src/api/ws-frames.ts | 2 + apps/web/src/components/PermissionCard.tsx | 378 ++++++++++++++++++- apps/web/src/components/panes/CoderPane.tsx | 8 +- 10 files changed, 507 insertions(+), 18 deletions(-) diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index e19df75..4223b6a 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -85,7 +85,9 @@ async function main() { type: 'permission_requested', task_id: prompt.taskId, session_id: prompt.sessionId, + kind: prompt.kind, tool_title: prompt.toolTitle, + ...(prompt.input ? { input: prompt.input } : {}), options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })), } as WsFrame); }, diff --git a/apps/coder/src/routes/tasks.ts b/apps/coder/src/routes/tasks.ts index f7553d9..6b7ebd3 100644 --- a/apps/coder/src/routes/tasks.ts +++ b/apps/coder/src/routes/tasks.ts @@ -19,6 +19,7 @@ const CreateBody = z.object({ const PermissionBody = z.object({ option_id: z.string().max(200).nullable(), + updated_input: z.record(z.unknown()).optional(), }); const ListQuery = z.object({ @@ -164,7 +165,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In return { error: 'invalid body', details: parsed.error.flatten() }; } - const ok = respondToPermission(req.params.id, parsed.data.option_id); + const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record | undefined); if (!ok) { reply.code(404); return { error: 'no pending permission' }; diff --git a/apps/coder/src/services/acp-dispatch.ts b/apps/coder/src/services/acp-dispatch.ts index de450fa..984e7b0 100644 --- a/apps/coder/src/services/acp-dispatch.ts +++ b/apps/coder/src/services/acp-dispatch.ts @@ -17,6 +17,8 @@ import { type WriteTextFileResponse, type CreateTerminalRequest, type CreateTerminalResponse, + type CreateElicitationRequest, + type CreateElicitationResponse, type SessionConfigOption, type ClientSideConnection as ConnectionType, } from '@agentclientprotocol/sdk'; @@ -26,7 +28,7 @@ import { spawn } from 'node:child_process'; import { findThoughtLevelConfigId } from './acp-derive.js'; import { resolveAcpSpawnArgs } from './acp-spawn.js'; import { createAcpNdJsonStream } from './acp-stream.js'; -import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js'; +import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js'; import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js'; import { @@ -254,6 +256,12 @@ class AcpStreamContext { createTerminal: async (_params: CreateTerminalRequest): Promise => { return { terminalId: 'noop' }; }, + unstable_createElicitation: async (params: CreateElicitationRequest): Promise => { + if (taskId && sessionId) { + return waitForElicitationResponse(taskId, sessionId, agent, modeId, params); + } + return { action: 'decline' }; + }, }; } } diff --git a/apps/coder/src/services/permission-waiter.ts b/apps/coder/src/services/permission-waiter.ts index 2a9fcf3..2f8fdb0 100644 --- a/apps/coder/src/services/permission-waiter.ts +++ b/apps/coder/src/services/permission-waiter.ts @@ -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; } -const pendingByTask = new Map(); +interface PendingElicitation { + type: 'elicitation'; + request: CreateElicitationRequest; + sessionId: string; + resolve: (response: CreateElicitationResponse) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +type PendingEntry = PendingPermission | PendingElicitation; + +const pendingByTask = new Map(); + +export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation'; export interface PermissionPrompt { taskId: string; + kind: PermissionKind; toolTitle?: string; + description?: string; + input?: Record; 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).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 + : 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): 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 = { 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 { + 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); } diff --git a/apps/server/src/types/ws-frames.ts b/apps/server/src/types/ws-frames.ts index ad38ddf..b743309 100644 --- a/apps/server/src/types/ws-frames.ts +++ b/apps/server/src/types/ws-frames.ts @@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({ type: z.literal('permission_requested'), task_id: Uuid, session_id: Uuid, + kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(), tool_title: z.string().optional(), + input: z.record(z.unknown()).optional(), options: z.array(PermissionOptionShape), }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6e0a547..3df8852 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -319,10 +319,10 @@ export const api = { }), getTaskPermission: (taskId: string) => request(`/api/coder/tasks/${taskId}/permission`), - respondTaskPermission: (taskId: string, optionId: string | null) => + respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record) => request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, { method: 'POST', - body: JSON.stringify({ option_id: optionId }), + body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }), }), getTaskCommands: (taskId: string) => request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`), diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 1a72c7d..cc31319 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -250,9 +250,13 @@ export interface AgentSessionConfig { thinkingOptionId: string | null; } +export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation'; + export interface PermissionPrompt { taskId: string; + kind?: PermissionKind; toolTitle?: string; + input?: Record; options: Array<{ optionId: string; label: string }>; } diff --git a/apps/web/src/api/ws-frames.ts b/apps/web/src/api/ws-frames.ts index ad38ddf..b743309 100644 --- a/apps/web/src/api/ws-frames.ts +++ b/apps/web/src/api/ws-frames.ts @@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({ type: z.literal('permission_requested'), task_id: Uuid, session_id: Uuid, + kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(), tool_title: z.string().optional(), + input: z.record(z.unknown()).optional(), options: z.array(PermissionOptionShape), }); diff --git a/apps/web/src/components/PermissionCard.tsx b/apps/web/src/components/PermissionCard.tsx index 65980bc..9c3d0bb 100644 --- a/apps/web/src/components/PermissionCard.tsx +++ b/apps/web/src/components/PermissionCard.tsx @@ -1,14 +1,105 @@ -import { ShieldAlert } from 'lucide-react'; +import { useState } from 'react'; +import { ShieldAlert, MessageCircleQuestion } from 'lucide-react'; import type { PermissionPrompt } from '@/api/types'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; interface Props { prompt: PermissionPrompt; - onRespond: (optionId: string | null) => void; + onRespond: (optionId: string | null, updatedInput?: Record) => void; busy?: boolean; } +// --------------------------------------------------------------------------- +// Question detection — ACP's RequestPermissionRequest carries the tool input +// in `input`. Claude Code's AskUserQuestion puts { questions: [...] } there. +// --------------------------------------------------------------------------- + +interface Question { + question: string; + header?: string; + options: string[]; + multiSelect: boolean; +} + +function parseQuestions(input: Record | undefined): Question[] | null { + if (!input) return null; + const raw = input.questions; + if (!Array.isArray(raw)) return null; + const out: Question[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const q = item as { question?: unknown; header?: unknown; options?: unknown; multiSelect?: unknown }; + if (typeof q.question !== 'string') continue; + const opts = Array.isArray(q.options) + ? q.options.filter((o): o is string => typeof o === 'string') + : []; + out.push({ + question: q.question, + header: typeof q.header === 'string' ? q.header : undefined, + options: opts, + multiSelect: q.multiSelect === true, + }); + } + return out.length > 0 ? out : null; +} + +// --------------------------------------------------------------------------- +// Elicitation detection — ACP's createElicitation carries a JSON Schema in +// `input.requestedSchema`. For now, render each property as a text input. +// --------------------------------------------------------------------------- + +interface ElicitationField { + key: string; + title: string; + description?: string; + type: string; + enumValues?: string[]; +} + +function parseElicitation(input: Record | undefined): { message: string; fields: ElicitationField[] } | null { + if (!input) return null; + const schema = input.requestedSchema; + if (!schema || typeof schema !== 'object') return null; + const s = schema as Record; + const props = s.properties; + if (!props || typeof props !== 'object') return null; + const fields: ElicitationField[] = []; + for (const [key, val] of Object.entries(props as Record)) { + if (!val || typeof val !== 'object') continue; + const p = val as Record; + fields.push({ + key, + title: typeof p.title === 'string' ? p.title : key, + description: typeof p.description === 'string' ? p.description : undefined, + type: typeof p.type === 'string' ? p.type : 'string', + enumValues: Array.isArray(p.enum) ? p.enum.filter((e): e is string => typeof e === 'string') : undefined, + }); + } + if (fields.length === 0) return null; + return { message: typeof input.message === 'string' ? input.message : '', fields }; +} + export function PermissionCard({ prompt, onRespond, busy }: Props) { + const isQuestion = prompt.kind === 'question'; + const isElicitation = prompt.kind === 'elicitation'; + + if (isQuestion) { + const questions = parseQuestions(prompt.input); + if (questions) { + return ; + } + } + + if (isElicitation) { + const elicitation = parseElicitation(prompt.input); + if (elicitation) { + return ; + } + } + + // Standard tool permission — approve/deny buttons return (
@@ -47,3 +138,286 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
); } + +// --------------------------------------------------------------------------- +// QuestionView — renders Claude's AskUserQuestion as interactive radio/checkbox +// --------------------------------------------------------------------------- + +function QuestionView({ + questions, + prompt, + onRespond, + busy, +}: { + questions: Question[]; + prompt: PermissionPrompt; + onRespond: Props['onRespond']; + busy?: boolean; +}) { + const [selections, setSelections] = useState(() => questions.map(() => [])); + const [freeTexts, setFreeTexts] = useState(() => questions.map(() => '')); + const [submitting, setSubmitting] = useState(false); + const disabled = busy || submitting; + + const allComplete = questions.every((_, i) => + selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0, + ); + + function buildAnswers(): Record { + const answers: Record = {}; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]!; + const key = q.question; + const selected = selections[i]!; + const free = freeTexts[i]!.trim(); + if (free) { + answers[key] = free; + } else if (selected.length > 0) { + answers[key] = selected.join(', '); + } + } + return answers; + } + + function handleSubmit() { + if (!allComplete || submitting) return; + setSubmitting(true); + const answers = buildAnswers(); + const firstAllow = prompt.options.find((o) => + o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'), + ); + onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, { + ...prompt.input, + answers, + }); + } + + function pickSingle(qIdx: number, option: string) { + setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr))); + if (questions.length === 1 && !freeTexts[0]!.trim()) { + setSubmitting(true); + const firstAllow = prompt.options.find((o) => + o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'), + ); + onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, { + ...prompt.input, + answers: { [questions[0]!.question]: option }, + }); + } + } + + function toggleMulti(qIdx: number, option: string) { + setSelections((prev) => + prev.map((arr, i) => { + if (i !== qIdx) return arr; + return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option]; + }), + ); + } + + return ( +
+
+ {questions.map((q, i) => ( +
+ {questions.length > 1 && ( +
+ {q.header ?? `Question ${i + 1}`} +
+ )} +
{q.question}
+ {q.options.length > 0 && !q.multiSelect && ( + pickSingle(i, v)} + disabled={disabled} + className="gap-1.5" + > + {q.options.map((opt, j) => { + const id = `q${i}-opt${j}`; + return ( + + ); + })} + + )} + {q.options.length > 0 && q.multiSelect && ( +
+ {q.options.map((opt, j) => { + const id = `q${i}-opt${j}`; + const checked = selections[i]!.includes(opt); + return ( + + ); + })} +
+ )} +
+
+ Or type a custom answer +
+ + setFreeTexts((prev) => prev.map((t, idx) => (idx === i ? e.target.value : t))) + } + className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60" + /> +
+
+ ))} +
+ {(questions.length > 1 || freeTexts.some((t) => t.trim())) && ( +
+ + +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// ElicitationView — renders ACP elicitation forms (JSON Schema-driven) +// --------------------------------------------------------------------------- + +function ElicitationView({ + elicitation, + prompt, + onRespond, + busy, +}: { + elicitation: { message: string; fields: ElicitationField[] }; + prompt: PermissionPrompt; + onRespond: Props['onRespond']; + busy?: boolean; +}) { + const [values, setValues] = useState>(() => { + const init: Record = {}; + for (const f of elicitation.fields) init[f.key] = ''; + return init; + }); + const [submitting, setSubmitting] = useState(false); + const disabled = busy || submitting; + + const allFilled = elicitation.fields.every((f) => (values[f.key] ?? '').trim().length > 0); + + function handleSubmit() { + if (!allFilled || submitting) return; + setSubmitting(true); + const content: Record = {}; + for (const f of elicitation.fields) { + const raw = values[f.key]!.trim(); + if (f.type === 'number' || f.type === 'integer') { + content[f.key] = Number(raw); + } else if (f.type === 'boolean') { + content[f.key] = raw === 'true' || raw === 'yes' || raw === '1'; + } else { + content[f.key] = raw; + } + } + const firstAllow = prompt.options[0]; + onRespond(firstAllow?.optionId ?? null, content); + } + + return ( +
+
+
+ +

{elicitation.message}

+
+ {elicitation.fields.map((f) => ( +
+ + {f.description && ( +

{f.description}

+ )} + {f.enumValues ? ( + setValues((prev) => ({ ...prev, [f.key]: v }))} + disabled={disabled} + className="gap-1.5" + > + {f.enumValues.map((opt, j) => { + const id = `e-${f.key}-${j}`; + return ( + + ); + })} + + ) : ( + setValues((prev) => ({ ...prev, [f.key]: e.target.value }))} + className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60" + /> + )} +
+ ))} +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index f363421..d0fc566 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -290,7 +290,9 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler } else if (frame.type === 'permission_requested') { handlersRef.current.onPermissionRequested?.({ taskId: frame.task_id, + kind: frame.kind, toolTitle: frame.tool_title, + ...(frame.input ? { input: frame.input as Record } : {}), options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({ optionId: o.option_id, label: o.label, @@ -565,11 +567,11 @@ export function CoderPane({ setProviderCommands(commands); }, []); - const handlePermissionRespond = useCallback(async (optionId: string | null) => { + const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record) => { if (!permissionPrompt) return; setPermissionBusy(true); try { - await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId); + await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput); setPermissionPrompt(null); } finally { setPermissionBusy(false); @@ -716,7 +718,7 @@ export function CoderPane({ {permissionPrompt && ( void handlePermissionRespond(id)} + onRespond={(id, input) => void handlePermissionRespond(id, input)} busy={permissionBusy} /> )}