Compare commits

...

2 Commits

Author SHA1 Message Date
6f6b3afb5d v2.3.2-coder-answer-endpoint: fix ask_user_input submit in CoderPane
The CoderPane runs its own inference runner and broker on the boocoder
service. The AskUserInputCard was calling /api/chats/:id/answer_user_input
on the main BooChat server, which has a different inference runner — the
answer was accepted but the next turn was enqueued on the wrong runner,
so nothing happened.

Fix: register the same answer_user_input endpoint on the boocoder, and
add an apiPrefix prop to AskUserInputCard so the CoderPane routes
through /api/coder/chats/:id/answer_user_input. BooChat's MessageList
continues to use the default (no prefix) path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 21:54:08 +00:00
154ef78f7c 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>
2026-05-26 21:28:14 +00:00
13 changed files with 682 additions and 27 deletions

View File

@@ -85,7 +85,9 @@ async function main() {
type: 'permission_requested', type: 'permission_requested',
task_id: prompt.taskId, task_id: prompt.taskId,
session_id: prompt.sessionId, session_id: prompt.sessionId,
kind: prompt.kind,
tool_title: prompt.toolTitle, tool_title: prompt.toolTitle,
...(prompt.input ? { input: prompt.input } : {}),
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })), options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame); } as WsFrame);
}, },

View File

@@ -5,6 +5,33 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/server/ws-frames';
import { resolveChatId } from './chat-resolve.js'; import { resolveChatId } from './chat-resolve.js';
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
answers: z
.array(
z.object({
question: z.string(),
selected_options: z.array(z.string()),
free_text: z.string().nullable(),
}),
)
.min(1)
.max(3),
});
const AskUserInputArgs = z.object({
questions: z
.array(
z.object({
question: z.string(),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string()).min(1),
}),
)
.min(1)
.max(3),
});
const SendBody = z.object({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
pane_id: z.string().min(1).max(200), pane_id: z.string().min(1).max(200),
@@ -219,6 +246,138 @@ export function registerMessageRoutes(
}, },
); );
// POST /api/chats/:id/answer_user_input — answer a pending ask_user_input
app.post<{ Params: { id: string } }>(
'/api/chats/:id/answer_user_input',
async (req, reply) => {
const parsed = AnswerUserInputBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, answers } = parsed.data;
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const callerRows = await sql<{
message_id: string;
payload: { id: string; name: string; args: Record<string, unknown> };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'assistant'
AND p.kind = 'tool_call'
AND p.payload->>'id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
if (!callerRows[0]) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
const foundCall = callerRows[0].payload;
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
}
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const questions = argsParsed.data.questions;
if (answers.length !== questions.length) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `expected ${questions.length} answer(s), got ${answers.length}` };
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const a = answers[i]!;
for (const sel of a.selected_options) {
if (!q.options.includes(sel)) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} option not in question: ${sel}` };
}
}
if (q.type === 'single_select' && a.selected_options.length > 1) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} multi on single_select` };
}
if (a.selected_options.length === 0 && (!a.free_text || !a.free_text.trim())) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
}
}
const toolRows = await sql<{
message_id: string;
payload: { tool_call_id: string; output: unknown };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'tool'
AND p.kind = 'tool_result'
AND p.payload->>'tool_call_id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
if (!toolRows[0]) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRows[0].payload?.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
const answerSet = { answers };
const newToolResults = { tool_call_id, output: answerSet, truncated: false };
const toolMessageId = toolRows[0].message_id;
const result = await sql.begin(async (tx) => {
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id };
});
broker.publishFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: answerSet,
truncated: false,
} as unknown as WsFrame);
inference.enqueue(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
// POST /api/sessions/:sessionId/stop — cancel active inference // POST /api/sessions/:sessionId/stop — cancel active inference
app.post<{ Params: { sessionId: string } }>( app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/stop', '/api/sessions/:sessionId/stop',

View File

@@ -19,6 +19,7 @@ const CreateBody = z.object({
const PermissionBody = z.object({ const PermissionBody = z.object({
option_id: z.string().max(200).nullable(), option_id: z.string().max(200).nullable(),
updated_input: z.record(z.unknown()).optional(),
}); });
const ListQuery = z.object({ 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() }; 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<string, unknown> | undefined);
if (!ok) { if (!ok) {
reply.code(404); reply.code(404);
return { error: 'no pending permission' }; return { error: 'no pending permission' };

View File

@@ -17,6 +17,8 @@ import {
type WriteTextFileResponse, type WriteTextFileResponse,
type CreateTerminalRequest, type CreateTerminalRequest,
type CreateTerminalResponse, type CreateTerminalResponse,
type CreateElicitationRequest,
type CreateElicitationResponse,
type SessionConfigOption, type SessionConfigOption,
type ClientSideConnection as ConnectionType, type ClientSideConnection as ConnectionType,
} from '@agentclientprotocol/sdk'; } from '@agentclientprotocol/sdk';
@@ -26,7 +28,7 @@ import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js'; import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveAcpSpawnArgs } from './acp-spawn.js'; import { resolveAcpSpawnArgs } from './acp-spawn.js';
import { createAcpNdJsonStream } from './acp-stream.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 { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js'; import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
import { import {
@@ -254,6 +256,12 @@ class AcpStreamContext {
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => { createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' }; return { terminalId: 'noop' };
}, },
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
if (taskId && sessionId) {
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
}
return { action: 'decline' };
},
}; };
} }
} }

View File

@@ -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'; import { isUnattendedMode } from './provider-manifest.js';
const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_TIMEOUT_MS = 120_000;
interface PendingPermission { interface PendingPermission {
type: 'permission';
request: RequestPermissionRequest; request: RequestPermissionRequest;
sessionId: string; sessionId: string;
resolve: (response: RequestPermissionResponse) => void; resolve: (response: RequestPermissionResponse) => void;
@@ -14,11 +15,27 @@ interface PendingPermission {
timer: ReturnType<typeof setTimeout>; 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 { export interface PermissionPrompt {
taskId: string; taskId: string;
kind: PermissionKind;
toolTitle?: string; toolTitle?: string;
description?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>; options: Array<{ optionId: string; label: string }>;
} }
@@ -33,10 +50,25 @@ export function setPermissionHooks(next: PermissionHooks): void {
hooks = next; 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 { 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 { return {
taskId, taskId,
kind,
toolTitle: params.toolCall?.title ?? undefined, toolTitle: params.toolCall?.title ?? undefined,
...(input ? { input } : {}),
options: params.options.map((o) => ({ options: params.options.map((o) => ({
optionId: o.optionId, optionId: o.optionId,
label: o.name, label: o.name,
@@ -73,24 +105,33 @@ export function waitForPermissionResponse(
resolve({ outcome: { outcome: 'cancelled' } }); resolve({ outcome: { outcome: 'cancelled' } });
}, timeoutMs); }, 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); const prompt = toPrompt(taskId, params);
void hooks.onPrompt?.({ ...prompt, sessionId }); 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); const pending = pendingByTask.get(taskId);
if (!pending) return false; if (!pending) return false;
clearTimeout(pending.timer); clearTimeout(pending.timer);
pendingByTask.delete(taskId); pendingByTask.delete(taskId);
if (optionId) { if (pending.type === 'elicitation') {
pending.resolve({ outcome: { outcome: 'selected', optionId } }); if (updatedInput) {
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
pending.resolve({ action: 'accept', content });
} else {
pending.resolve({ action: 'decline' });
}
} else { } 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); 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 { export function getPendingPermission(taskId: string): PermissionPrompt | null {
const pending = pendingByTask.get(taskId); const pending = pendingByTask.get(taskId);
if (!pending) return null; if (!pending) return null;
if (pending.type === 'elicitation') {
return elicitationToPrompt(taskId, pending.request);
}
return toPrompt(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 { export function cancelPendingPermission(taskId: string): void {
const pending = pendingByTask.get(taskId); const pending = pendingByTask.get(taskId);
if (!pending) return; if (!pending) return;
clearTimeout(pending.timer); clearTimeout(pending.timer);
pendingByTask.delete(taskId); 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); void hooks.onResolved?.(taskId, pending.sessionId);
} }

View File

@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
type: z.literal('permission_requested'), type: z.literal('permission_requested'),
task_id: Uuid, task_id: Uuid,
session_id: Uuid, session_id: Uuid,
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
tool_title: z.string().optional(), tool_title: z.string().optional(),
input: z.record(z.unknown()).optional(),
options: z.array(PermissionOptionShape), options: z.array(PermissionOptionShape),
}); });

View File

@@ -319,10 +319,10 @@ export const api = {
}), }),
getTaskPermission: (taskId: string) => getTaskPermission: (taskId: string) =>
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`), request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
respondTaskPermission: (taskId: string, optionId: string | null) => respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, { request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ option_id: optionId }), body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
}), }),
getTaskCommands: (taskId: string) => getTaskCommands: (taskId: string) =>
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`), request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),

View File

@@ -250,9 +250,13 @@ export interface AgentSessionConfig {
thinkingOptionId: string | null; thinkingOptionId: string | null;
} }
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
export interface PermissionPrompt { export interface PermissionPrompt {
taskId: string; taskId: string;
kind?: PermissionKind;
toolTitle?: string; toolTitle?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>; options: Array<{ optionId: string; label: string }>;
} }

View File

@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
type: z.literal('permission_requested'), type: z.literal('permission_requested'),
task_id: Uuid, task_id: Uuid,
session_id: Uuid, session_id: Uuid,
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
tool_title: z.string().optional(), tool_title: z.string().optional(),
input: z.record(z.unknown()).optional(),
options: z.array(PermissionOptionShape), options: z.array(PermissionOptionShape),
}); });

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { import type {
@@ -22,6 +21,7 @@ interface Props {
toolCall: ToolCall; toolCall: ToolCall;
toolResult: ToolResult | null; toolResult: ToolResult | null;
chatId: string; chatId: string;
apiPrefix?: string;
} }
function parseQuestions(raw: unknown): AskUserQuestion[] { function parseQuestions(raw: unknown): AskUserQuestion[] {
@@ -63,7 +63,7 @@ function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
return { answers }; return { answers };
} }
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) { export function AskUserInputCard({ toolCall, toolResult, chatId, apiPrefix = '' }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]); const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) { if (questions.length === 0) {
@@ -74,9 +74,6 @@ export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
); );
} }
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null; const answered = toolResult && toolResult.output !== null;
if (answered) { if (answered) {
const answerSet = parseAnswerSet(toolResult!.output); const answerSet = parseAnswerSet(toolResult!.output);
@@ -84,7 +81,7 @@ export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
} }
return ( return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} /> <PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} apiPrefix={apiPrefix} />
); );
} }
@@ -92,10 +89,12 @@ function PendingView({
questions, questions,
toolCallId, toolCallId,
chatId, chatId,
apiPrefix = '',
}: { }: {
questions: AskUserQuestion[]; questions: AskUserQuestion[];
toolCallId: string; toolCallId: string;
chatId: string; chatId: string;
apiPrefix?: string;
}) { }) {
// Per-question selections + free text. Selections are option arrays so the // Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1. // multi_select case is uniform; single_select just constrains to length 1.
@@ -133,9 +132,16 @@ function PendingView({
if (submitting) return; if (submitting) return;
setSubmitting(true); setSubmitting(true);
try { try {
await api.chats.answerUserInput(chatId, toolCallId, answers); const url = `${apiPrefix}/api/chats/${chatId}/answer_user_input`;
// Card stays mounted; the incoming WS tool_result frame will flip it const res = await fetch(url, {
// into AnsweredView via the parent prop change. method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string; detail?: string };
throw new Error(body.detail ?? body.error ?? `HTTP ${res.status}`);
}
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'submit failed'); toast.error(err instanceof Error ? err.message : 'submit failed');
setSubmitting(false); setSubmitting(false);

View File

@@ -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 type { PermissionPrompt } from '@/api/types';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
prompt: PermissionPrompt; prompt: PermissionPrompt;
onRespond: (optionId: string | null) => void; onRespond: (optionId: string | null, updatedInput?: Record<string, unknown>) => void;
busy?: boolean; 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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
const props = s.properties;
if (!props || typeof props !== 'object') return null;
const fields: ElicitationField[] = [];
for (const [key, val] of Object.entries(props as Record<string, unknown>)) {
if (!val || typeof val !== 'object') continue;
const p = val as Record<string, unknown>;
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) { 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 <QuestionView questions={questions} prompt={prompt} onRespond={onRespond} busy={busy} />;
}
}
if (isElicitation) {
const elicitation = parseElicitation(prompt.input);
if (elicitation) {
return <ElicitationView elicitation={elicitation} prompt={prompt} onRespond={onRespond} busy={busy} />;
}
}
// Standard tool permission — approve/deny buttons
return ( return (
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm"> <div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -47,3 +138,286 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// 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<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => 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<string, string> {
const answers: Record<string, string> = {};
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 (
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
{q.header ?? `Question ${i + 1}`}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.options.length > 0 && !q.multiSelect && (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={disabled}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
)}
{q.options.length > 0 && q.multiSelect && (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={disabled}
placeholder="Free text…"
onChange={(e) =>
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"
/>
</div>
</div>
))}
</div>
{(questions.length > 1 || freeTexts.some((t) => t.trim())) && (
<div className="flex justify-between items-center border-t px-4 py-2">
<button
type="button"
disabled={disabled}
onClick={() => onRespond(null)}
className="text-xs text-destructive hover:underline disabled:opacity-40"
>
Dismiss
</button>
<Button
type="button"
size="sm"
disabled={!allComplete || disabled}
onClick={handleSubmit}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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<Record<string, string>>(() => {
const init: Record<string, string> = {};
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<string, unknown> = {};
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 (
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-3">
<div className="flex items-start gap-2">
<MessageCircleQuestion className="size-4 text-blue-500 shrink-0 mt-0.5" />
<p className="font-medium leading-snug">{elicitation.message}</p>
</div>
{elicitation.fields.map((f) => (
<div key={f.key} className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{f.title}</label>
{f.description && (
<p className="text-[11px] text-muted-foreground/70">{f.description}</p>
)}
{f.enumValues ? (
<RadioGroup
value={values[f.key] ?? ''}
onValueChange={(v) => setValues((prev) => ({ ...prev, [f.key]: v }))}
disabled={disabled}
className="gap-1.5"
>
{f.enumValues.map((opt, j) => {
const id = `e-${f.key}-${j}`;
return (
<label key={j} htmlFor={id} className="flex items-start gap-2 text-sm cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40">
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<input
type={f.type === 'number' || f.type === 'integer' ? 'number' : 'text'}
value={values[f.key] ?? ''}
disabled={disabled}
onChange={(e) => 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"
/>
)}
</div>
))}
</div>
<div className="flex justify-between items-center border-t px-4 py-2">
<button
type="button"
disabled={disabled}
onClick={() => onRespond(null)}
className="text-xs text-destructive hover:underline disabled:opacity-40"
>
Dismiss
</button>
<Button
type="button"
size="sm"
disabled={!allFilled || disabled}
onClick={handleSubmit}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
</div>
);
}

View File

@@ -230,6 +230,7 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
toolCall={item.run.call} toolCall={item.run.call}
toolResult={item.run.result} toolResult={item.run.result}
chatId={chatId} chatId={chatId}
apiPrefix="/api/coder"
/> />
); );
} }

View File

@@ -290,7 +290,9 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
} else if (frame.type === 'permission_requested') { } else if (frame.type === 'permission_requested') {
handlersRef.current.onPermissionRequested?.({ handlersRef.current.onPermissionRequested?.({
taskId: frame.task_id, taskId: frame.task_id,
kind: frame.kind,
toolTitle: frame.tool_title, toolTitle: frame.tool_title,
...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({ options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
optionId: o.option_id, optionId: o.option_id,
label: o.label, label: o.label,
@@ -565,11 +567,11 @@ export function CoderPane({
setProviderCommands(commands); setProviderCommands(commands);
}, []); }, []);
const handlePermissionRespond = useCallback(async (optionId: string | null) => { const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record<string, unknown>) => {
if (!permissionPrompt) return; if (!permissionPrompt) return;
setPermissionBusy(true); setPermissionBusy(true);
try { try {
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId); await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
setPermissionPrompt(null); setPermissionPrompt(null);
} finally { } finally {
setPermissionBusy(false); setPermissionBusy(false);
@@ -716,7 +718,7 @@ export function CoderPane({
{permissionPrompt && ( {permissionPrompt && (
<PermissionCard <PermissionCard
prompt={permissionPrompt} prompt={permissionPrompt}
onRespond={(id) => void handlePermissionRespond(id)} onRespond={(id, input) => void handlePermissionRespond(id, input)}
busy={permissionBusy} busy={permissionBusy}
/> />
)} )}