From d85b17081eb4c813b065fa004bce3746ef9c385a Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 18 May 2026 02:15:18 +0000 Subject: [PATCH] v1.9.7: ask_user_input elicitation tool --- apps/server/src/index.ts | 3 + apps/server/src/routes/messages.ts | 207 +++++++++++- apps/server/src/services/agents.ts | 3 + apps/server/src/services/inference.ts | 33 ++ apps/server/src/services/tools.ts | 79 +++++ apps/web/src/api/client.ts | 12 + apps/web/src/api/types.ts | 21 ++ apps/web/src/components/AskUserInputCard.tsx | 324 +++++++++++++++++++ apps/web/src/components/MessageList.tsx | 32 +- 9 files changed, 710 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/AskUserInputCard.tsx diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index de11b9a..58d463d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -123,6 +123,9 @@ async function main() { chat_id: chatId, }); }, + publishSessionFrame: (sessionId, frame) => { + broker.publish(sessionId, frame); + }, }); registerSkillsRoutes(app, sql, { enqueueInference: (sessionId, chatId, assistantId, user) => { diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts index a33e4e6..4c0e666 100644 --- a/apps/server/src/routes/messages.ts +++ b/apps/server/src/routes/messages.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; -import type { Chat, Message, Session } from '../types/api.js'; +import type { Chat, Message, Session, ToolCall } from '../types/api.js'; const SendBody = z.object({ content: z.string().min(1).max(64_000), @@ -14,6 +14,39 @@ const ContinueBody = z.object({ sentinel_message_id: z.string().uuid(), }); +// Batch 9.7: ask_user_input answer submission. Defensive shape — the question +// content is echoed back for traceability but the server does NOT trust it +// (the source of truth is the assistant message's tool_calls.args.questions). +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), +}); + +// Same shape the model declared via the tool's zod input. Re-derived here so +// the route can validate args without depending on services/tools.ts (which +// would pull in fs/path_guard for nothing). +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), +}); + interface MessageHandlers { enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void; enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void; @@ -24,6 +57,13 @@ interface MessageHandlers { content: string ) => void; publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void; + // Batch 9.7: lets the answer endpoint emit the tool_result frame that the + // pause path intentionally skipped. Matches SkillInvokeHandlers in + // routes/skills.ts so index.ts can pass the same broker.publish adapter. + publishSessionFrame: ( + sessionId: string, + frame: Record & { type: string } + ) => void; cancelInference: (sessionId: string, chatId: string) => Promise; hasActiveInference: (chatId: string) => boolean; } @@ -389,4 +429,169 @@ export function registerMessageRoutes( return result; } ); + + // Batch 9.7: resume an ask_user_input pause. Validates the body matches the + // question shape the model declared, UPDATEs the pending tool row's + // tool_results to the AnswerSet, publishes the deferred tool_result frame, + // and enqueues the next assistant turn. Error codes per spec: + // 400 invalid_body / mismatched_answer_shape + // 404 chat_not_found / unknown_tool_call_id + // 409 tool_call_already_answered + 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` + 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; + + // Find the assistant message that emitted this tool_call. Scoped by + // chat_id + role to avoid cross-chat lookups; ordered by created_at DESC + // because the most recent issuance wins when an LLM reuses call IDs + // across turns (the older, already-answered one is a different row with + // populated tool_results downstream). + const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>` + SELECT id, tool_calls FROM messages + WHERE chat_id = ${chat.id} + AND role = 'assistant' + AND tool_calls IS NOT NULL + ORDER BY created_at DESC + `; + let foundCall: ToolCall | null = null; + for (const row of callerRows) { + const match = row.tool_calls?.find((tc) => tc.id === tool_call_id); + if (match) { + foundCall = match; + break; + } + } + if (!foundCall) { + reply.code(404); + return { error: 'unknown_tool_call_id' }; + } + if (foundCall.name !== 'ask_user_input') { + reply.code(400); + return { error: 'tool_call_not_ask_user_input' }; + } + + // Validate the args themselves — the LLM could have emitted bad JSON. + 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} contains 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} has multiple selections on single_select`, + }; + } + const hasOpt = a.selected_options.length > 0; + const hasText = a.free_text !== null && a.free_text.trim().length > 0; + if (!hasOpt && !hasText) { + reply.code(400); + return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` }; + } + } + + // Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks + // the most recent row with this tool_call_id; the already-answered + // check below guards against UPDATE-ing a stale answer. + const toolRows = await sql<{ + id: string; + tool_results: { tool_call_id: string; output: unknown } | null; + }[]>` + SELECT id, tool_results FROM messages + WHERE chat_id = ${chat.id} + AND role = 'tool' + AND tool_results->>'tool_call_id' = ${tool_call_id} + ORDER BY created_at DESC + LIMIT 1 + `; + const toolRow = toolRows[0]; + if (!toolRow) { + reply.code(404); + return { error: 'unknown_tool_call_id', detail: 'tool message not found' }; + } + if (toolRow.tool_results && toolRow.tool_results.output !== null) { + reply.code(409); + return { error: 'tool_call_already_answered' }; + } + + const answerSet = { answers }; + const newToolResults = { + tool_call_id, + output: answerSet, + truncated: false, + }; + + const result = await sql.begin(async (tx) => { + await tx` + UPDATE messages + SET tool_results = ${tx.json(newToolResults as never)} + WHERE id = ${toolRow.id} + `; + 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: toolRow.id, + assistant_message_id: assistantMsg!.id, + }; + }); + + // Publish the deferred tool_result frame. useSessionStream's reducer + // updates the matching tool_run.result so AskUserInputCard flips into + // its read-only "answered" mode without a refetch. + handlers.publishSessionFrame(sessionId, { + type: 'tool_result', + tool_message_id: result.tool_message_id, + tool_call_id, + chat_id: chat.id, + output: answerSet, + truncated: false, + }); + handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); + + reply.code(202); + return result; + }, + ); } diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts index 268cc06..b9d8486 100644 --- a/apps/server/src/services/agents.ts +++ b/apps/server/src/services/agents.ts @@ -15,9 +15,12 @@ const CACHE_TTL_MS = 60_000; // explicit `tools:` field inherit the full default set (which now includes // the skill tools); agents with an explicit `tools:` array must list any // skill tool they want to use — strict opt-in. +// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an +// explicit tools list that omits it cannot trigger the interactive picker. const ALL_TOOL_NAMES = [ 'view_file', 'list_dir', 'grep', 'find_files', 'git_status', 'skill_find', 'skill_use', 'skill_resource', + 'ask_user_input', ] as const; const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; const DEFAULT_TEMPERATURE = 0.7; diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index 1c24f8e..8c4af97 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -665,6 +665,12 @@ async function executeToolPhase( model: session.model, }); + // Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted + // (the answer endpoint needs a target row to UPDATE), but tool_results is + // pre-stamped with output=null as a "pending" sentinel and no tool_result + // frame goes out — the card renders from the tool_call frame alone. Mixed + // batches still execute the other tools normally. + let pausingForUserInput = false; await Promise.all( toolCalls.map(async (tc) => { const [toolRow] = await ctx.sql<{ id: string }[]>` @@ -673,6 +679,16 @@ async function executeToolPhase( RETURNING id `; const toolMessageId = toolRow!.id; + if (tc.name === 'ask_user_input') { + pausingForUserInput = true; + const sentinel = { tool_call_id: tc.id, output: null, truncated: false }; + await ctx.sql` + UPDATE messages + SET tool_results = ${ctx.sql.json(sentinel as never)} + WHERE id = ${toolMessageId} + `; + return; + } const tres = await executeToolCall(projectRoot, tc); const stored = { tool_call_id: tc.id, @@ -697,6 +713,23 @@ async function executeToolPhase( }) ); + if (pausingForUserInput) { + // Drop the dot back to idle — the card is the actionable surface now. + // The next inference turn fires from POST /api/chats/:id/answer_user_input + // once the user submits their answers. + ctx.publishUser({ + type: 'chat_status', + chat_id: chatId, + status: 'idle', + at: new Date().toISOString(), + }); + ctx.log.info( + { sessionId, chatId, assistantMessageId }, + 'inference paused awaiting user input', + ); + return; + } + const [nextAssistant] = await ctx.sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index b5e20d6..c01b619 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -405,6 +405,81 @@ export const skillResource: ToolDef = { }, }; +// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool +// call with 1-3 structured questions; the inference loop PAUSES (does not +// execute the tool server-side, does not recurse) and waits for the frontend +// to POST /api/chats/:id/answer_user_input with the user's selections. See +// routes/messages.ts for the resume path and services/inference.ts for the +// pause branch in executeToolPhase. +const AskUserInputInput = z.object({ + questions: z + .array( + z.object({ + question: z.string().min(1).max(200), + type: z.enum(['single_select', 'multi_select']), + options: z.array(z.string().min(1).max(80)).min(2).max(6), + }), + ) + .min(1) + .max(3), +}); +type AskUserInputInputT = z.infer; + +export const askUserInput: ToolDef = { + name: 'ask_user_input', + description: + "Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.", + inputSchema: AskUserInputInput, + jsonSchema: { + type: 'function', + function: { + name: 'ask_user_input', + description: + 'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.', + parameters: { + type: 'object', + properties: { + questions: { + type: 'array', + minItems: 1, + maxItems: 3, + items: { + type: 'object', + properties: { + question: { type: 'string', description: '<=200 chars, shown to the user' }, + type: { + type: 'string', + enum: ['single_select', 'multi_select'], + description: 'single_select = at most one option; multi_select = any subset', + }, + options: { + type: 'array', + minItems: 2, + maxItems: 6, + items: { type: 'string' }, + description: '2-6 strings, each <=80 chars; free-text input is always available alongside', + }, + }, + required: ['question', 'type', 'options'], + additionalProperties: false, + }, + }, + }, + required: ['questions'], + additionalProperties: false, + }, + }, + }, + // Server-side no-op. The "execution" of ask_user_input is the user's + // response, captured client-side and posted to /api/chats/:id/answer_user_input. + // The inference loop detects this tool by name and pauses before reaching + // executeToolCall — this fallback only runs if something bypasses that + // branch, in which case the pending sentinel matches the pause-path shape. + async execute(input) { + return { _pending: true, questions: input.questions }; + }, +}; + export const ALL_TOOLS: ReadonlyArray> = [ viewFile as ToolDef, listDir as ToolDef, @@ -414,6 +489,7 @@ export const ALL_TOOLS: ReadonlyArray> = [ skillFind as ToolDef, skillUse as ToolDef, skillResource as ToolDef, + askUserInput as ToolDef, ]; // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is @@ -422,6 +498,8 @@ export const ALL_TOOLS: ReadonlyArray> = [ // default (10). Every tool in v1.8.2 happens to be read-only, so the // non-RO branch only takes effect once BooCoder lands write tools. // Batch 9.6: skill_* added; all still read-only. +// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate +// project state, so it belongs in the read-only set for budget purposes. export const READ_ONLY_TOOL_NAMES = [ 'view_file', 'list_dir', @@ -431,6 +509,7 @@ export const READ_ONLY_TOOL_NAMES = [ 'skill_find', 'skill_use', 'skill_resource', + 'ask_user_input', ] as const; export const TOOLS_BY_NAME: Record> = Object.fromEntries( diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index fae6375..d887596 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -11,6 +11,7 @@ import type { AgentsResponse, GitMeta, Skill, + AskUserAnswer, } from './types'; export class ApiError extends Error { @@ -202,6 +203,17 @@ export const api = { method: 'POST', body: JSON.stringify({ skill_name: skillName, user_message: userMessage }), }), + // Batch 9.7: submit answers for a paused ask_user_input call. Server + // validates against the question shape, UPDATEs the pending tool row, + // publishes the deferred tool_result frame, and enqueues the next turn. + answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) => + request<{ tool_message_id: string; assistant_message_id: string }>( + `/api/chats/${chatId}/answer_user_input`, + { + method: 'POST', + body: JSON.stringify({ tool_call_id: toolCallId, answers }), + }, + ), }, messages: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 3dbf4a4..5d719fd 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -241,6 +241,27 @@ export interface Skill { mtime: number; } +// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] } +// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the +// same order. AskUserInputCard renders questions and POSTs answers. +export type AskUserQuestionType = 'single_select' | 'multi_select'; + +export interface AskUserQuestion { + question: string; + type: AskUserQuestionType; + options: string[]; +} + +export interface AskUserAnswer { + question: string; + selected_options: string[]; + free_text: string | null; +} + +export interface AskUserAnswerSet { + answers: AskUserAnswer[]; +} + // v1.9: 'settings' is an ephemeral pane kind — never persisted, always // singleton per workspace. The pane hook filters it out before writing to // localStorage and dedupes on insertion via toggleSettingsPane(). diff --git a/apps/web/src/components/AskUserInputCard.tsx b/apps/web/src/components/AskUserInputCard.tsx new file mode 100644 index 0000000..e0e7e8b --- /dev/null +++ b/apps/web/src/components/AskUserInputCard.tsx @@ -0,0 +1,324 @@ +import { useMemo, useState } from 'react'; +import { Check } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api/client'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Button } from '@/components/ui/button'; +import type { + AskUserAnswer, + AskUserAnswerSet, + AskUserQuestion, + ToolCall, + ToolResult, +} from '@/api/types'; + +// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of +// the standard ToolCallLine when the assistant emits an ask_user_input tool +// call. While the tool result is null (server pre-stamps a sentinel with +// output=null), shows the form; once the WS tool_result frame arrives with a +// real AnswerSet, flips to read-only review mode. + +interface Props { + toolCall: ToolCall; + toolResult: ToolResult | null; + chatId: string; +} + +function parseQuestions(raw: unknown): AskUserQuestion[] { + if (!raw || typeof raw !== 'object' || !('questions' in raw)) return []; + const arr = (raw as { questions: unknown }).questions; + if (!Array.isArray(arr)) return []; + const out: AskUserQuestion[] = []; + for (const item of arr) { + if (!item || typeof item !== 'object') continue; + const q = item as { question?: unknown; type?: unknown; options?: unknown }; + if (typeof q.question !== 'string') continue; + if (q.type !== 'single_select' && q.type !== 'multi_select') continue; + if (!Array.isArray(q.options)) continue; + const opts = q.options.filter((o): o is string => typeof o === 'string'); + if (opts.length < 2) continue; + out.push({ question: q.question, type: q.type, options: opts }); + } + return out; +} + +function parseAnswerSet(raw: unknown): AskUserAnswerSet | null { + if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null; + const arr = (raw as { answers: unknown }).answers; + if (!Array.isArray(arr)) return null; + const answers: AskUserAnswer[] = []; + for (const item of arr) { + if (!item || typeof item !== 'object') continue; + const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown }; + if (typeof a.question !== 'string') continue; + if (!Array.isArray(a.selected_options)) continue; + if (a.free_text !== null && typeof a.free_text !== 'string') continue; + const sel = a.selected_options.filter((s): s is string => typeof s === 'string'); + answers.push({ + question: a.question, + selected_options: sel, + free_text: (a.free_text as string | null) ?? null, + }); + } + return { answers }; +} + +export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) { + const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]); + + if (questions.length === 0) { + return ( +
+ ask_user_input: malformed tool args +
+ ); + } + + // 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; + if (answered) { + const answerSet = parseAnswerSet(toolResult!.output); + return ; + } + + return ( + + ); +} + +function PendingView({ + questions, + toolCallId, + chatId, +}: { + questions: AskUserQuestion[]; + toolCallId: string; + chatId: string; +}) { + // Per-question selections + free text. Selections are option arrays so the + // multi_select case is uniform; single_select just constrains to length 1. + const [selections, setSelections] = useState(() => questions.map(() => [])); + const [freeTexts, setFreeTexts] = useState(() => questions.map(() => '')); + const [submitting, setSubmitting] = useState(false); + + const singleQuestion = questions.length === 1; + const anyFreeText = freeTexts.some((t) => t.trim().length > 0); + + // Submit button shows when: + // - more than one question (always batched), OR + // - one question and the user has typed free text (committing it needs an + // explicit Submit so an accidental Tab/click doesn't lose it). + // For one question with no free text, clicking an option submits inline. + const showSubmitButton = !singleQuestion || anyFreeText; + + // Every question must have at least one of (option, free text). + const allComplete = questions.every((_, i) => { + return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0; + }); + + function buildAnswers(): AskUserAnswer[] { + return questions.map((q, i) => { + const freeText = freeTexts[i]!.trim(); + return { + question: q.question, + selected_options: selections[i]!, + free_text: freeText.length > 0 ? freeText : null, + }; + }); + } + + async function submit(answers: AskUserAnswer[]) { + if (submitting) return; + setSubmitting(true); + try { + await api.chats.answerUserInput(chatId, toolCallId, answers); + // Card stays mounted; the incoming WS tool_result frame will flip it + // into AnsweredView via the parent prop change. + } catch (err) { + toast.error(err instanceof Error ? err.message : 'submit failed'); + setSubmitting(false); + } + } + + function pickSingle(qIdx: number, option: string) { + setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr))); + // Immediate submit for the single-question single-select shortcut. Only + // fires when no free text exists anywhere — once the user typed, the + // Submit button takes over so the typed text isn't silently dropped. + if (singleQuestion && !anyFreeText) { + const answers: AskUserAnswer[] = [ + { + question: questions[0]!.question, + selected_options: [option], + free_text: null, + }, + ]; + void submit(answers); + } + } + + 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]; + }), + ); + } + + function setFreeText(qIdx: number, value: string) { + setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t))); + } + + return ( +
+
+ {questions.map((q, i) => ( +
+ {questions.length > 1 && ( +
+ Question {i + 1} +
+ )} +
{q.question}
+ {q.type === 'single_select' ? ( + pickSingle(i, v)} + disabled={submitting} + className="gap-1.5" + > + {q.options.map((opt, j) => { + const id = `q${i}-opt${j}`; + return ( + + ); + })} + + ) : ( +
+ {q.options.map((opt, j) => { + const id = `q${i}-opt${j}`; + const checked = selections[i]!.includes(opt); + return ( + + ); + })} +
+ )} +
+
+ Or type a custom answer +
+ setFreeText(i, 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" + /> +
+
+ ))} +
+ {showSubmitButton && ( +
+ +
+ )} +
+ ); +} + +function AnsweredView({ + questions, + answers, +}: { + questions: AskUserQuestion[]; + answers: AskUserAnswerSet | null; +}) { + if (!answers) { + return ( +
+ ask_user_input: answers unavailable +
+ ); + } + + return ( +
+
+ {questions.map((q, i) => { + const a = answers.answers[i]; + if (!a) return null; + return ( +
+ {questions.length > 1 && ( +
+ Question {i + 1} +
+ )} +
{q.question}
+
+ {q.options.map((opt, j) => { + const selected = a.selected_options.includes(opt); + return ( +
+ + {selected && } + + {opt} +
+ ); + })} +
+ {a.free_text && ( +
+ {a.free_text} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/MessageList.tsx b/apps/web/src/components/MessageList.tsx index 0e4f1c9..f90267a 100644 --- a/apps/web/src/components/MessageList.tsx +++ b/apps/web/src/components/MessageList.tsx @@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types'; import { MessageBubble } from './MessageBubble'; import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallLine, type ToolRun } from './ToolCallLine'; +import { AskUserInputCard } from './AskUserInputCard'; interface Props { messages: Message[]; @@ -12,9 +13,11 @@ interface Props { // v1.8.2: pre-render units. The single linear `messages` array gets walked // into a render-time list where each tool_call is a first-class item and // tool_result messages are folded onto their matching tool_run by id. +// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the +// answer without threading the chat id through MessageList's parent. type RenderItem = | { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } } - | { kind: 'tool_run'; run: ToolRun; key: string } + | { kind: 'tool_run'; run: ToolRun; key: string; chatId: string } | { kind: 'tool_group'; runs: ToolRun[]; key: string }; const GROUP_THRESHOLD = 3; @@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] { for (const tc of m.tool_calls!) { const run: ToolRun = { call: tc, result: null }; runsByCallId.set(tc.id, run); - items.push({ kind: 'tool_run', run, key: tc.id }); + items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id }); } continue; } @@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] { // Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items // of the same tool name into a single tool_group. Any other render item // (text bubble, sentinel, user message) breaks the chain. +// Batch 9.7: ask_user_input never groups — each pause has its own card so +// grouping would render them as collapsed ToolCallLines which can't surface +// the interactive form. function group(items: RenderItem[]): RenderItem[] { const out: RenderItem[] = []; let i = 0; @@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] { continue; } const name = item.run.call.name; + if (name === 'ask_user_input') { + out.push(item); + i += 1; + continue; + } let j = i + 1; while ( j < items.length && @@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] { ) { j += 1; } - const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>; + const run = items.slice(i, j) as Array<{ + kind: 'tool_run'; + run: ToolRun; + key: string; + chatId: string; + }>; if (run.length >= GROUP_THRESHOLD) { out.push({ kind: 'tool_group', @@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) { ); } if (item.kind === 'tool_run') { + if (item.run.call.name === 'ask_user_input') { + return ( + + ); + } return ; } return ;