v1.9.7: ask_user_input elicitation tool

This commit is contained in:
2026-05-18 02:15:18 +00:00
parent adb5d7b3bb
commit d85b17081e
9 changed files with 710 additions and 4 deletions

View File

@@ -123,6 +123,9 @@ async function main() {
chat_id: chatId, chat_id: chatId,
}); });
}, },
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
}); });
registerSkillsRoutes(app, sql, { registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => { enqueueInference: (sessionId, chatId, assistantId, user) => {

View File

@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; 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({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
@@ -14,6 +14,39 @@ const ContinueBody = z.object({
sentinel_message_id: z.string().uuid(), 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 { interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void; enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void; enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
@@ -24,6 +57,13 @@ interface MessageHandlers {
content: string content: string
) => void; ) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: 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<string, unknown> & { type: string }
) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>; cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean; hasActiveInference: (chatId: string) => boolean;
} }
@@ -389,4 +429,169 @@ export function registerMessageRoutes(
return result; 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<Chat[]>`
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;
},
);
} }

View File

@@ -15,9 +15,12 @@ const CACHE_TTL_MS = 60_000;
// explicit `tools:` field inherit the full default set (which now includes // explicit `tools:` field inherit the full default set (which now includes
// the skill tools); agents with an explicit `tools:` array must list any // the skill tools); agents with an explicit `tools:` array must list any
// skill tool they want to use — strict opt-in. // 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 = [ const ALL_TOOL_NAMES = [
'view_file', 'list_dir', 'grep', 'find_files', 'git_status', 'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
'skill_find', 'skill_use', 'skill_resource', 'skill_find', 'skill_use', 'skill_resource',
'ask_user_input',
] as const; ] as const;
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
const DEFAULT_TEMPERATURE = 0.7; const DEFAULT_TEMPERATURE = 0.7;

View File

@@ -665,6 +665,12 @@ async function executeToolPhase(
model: session.model, 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( await Promise.all(
toolCalls.map(async (tc) => { toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>` const [toolRow] = await ctx.sql<{ id: string }[]>`
@@ -673,6 +679,16 @@ async function executeToolPhase(
RETURNING id RETURNING id
`; `;
const toolMessageId = toolRow!.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 tres = await executeToolCall(projectRoot, tc);
const stored = { const stored = {
tool_call_id: tc.id, 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 }[]>` const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())

View File

@@ -405,6 +405,81 @@ export const skillResource: ToolDef<SkillResourceInputT> = {
}, },
}; };
// 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<typeof AskUserInputInput>;
export const askUserInput: ToolDef<AskUserInputInputT> = {
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<ToolDef<unknown>> = [ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>, viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>, listDir as ToolDef<unknown>,
@@ -414,6 +489,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
skillFind as ToolDef<unknown>, skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>, skillUse as ToolDef<unknown>,
skillResource as ToolDef<unknown>, skillResource as ToolDef<unknown>,
askUserInput as ToolDef<unknown>,
]; ];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -422,6 +498,8 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// default (10). Every tool in v1.8.2 happens to be read-only, so the // 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. // non-RO branch only takes effect once BooCoder lands write tools.
// Batch 9.6: skill_* added; all still read-only. // 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 = [ export const READ_ONLY_TOOL_NAMES = [
'view_file', 'view_file',
'list_dir', 'list_dir',
@@ -431,6 +509,7 @@ export const READ_ONLY_TOOL_NAMES = [
'skill_find', 'skill_find',
'skill_use', 'skill_use',
'skill_resource', 'skill_resource',
'ask_user_input',
] as const; ] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries( export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -11,6 +11,7 @@ import type {
AgentsResponse, AgentsResponse,
GitMeta, GitMeta,
Skill, Skill,
AskUserAnswer,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -202,6 +203,17 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }), 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: { messages: {

View File

@@ -241,6 +241,27 @@ export interface Skill {
mtime: number; 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 // v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to // singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via toggleSettingsPane(). // localStorage and dedupes on insertion via toggleSettingsPane().

View File

@@ -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 (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// 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 <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
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<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => 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 (
<div className="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">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
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>
) : (
<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={submitting}
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={submitting}
placeholder="Free text…"
onChange={(e) => 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"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine'; import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
interface Props { interface Props {
messages: Message[]; messages: Message[];
@@ -12,9 +13,11 @@ interface Props {
// v1.8.2: pre-render units. The single linear `messages` array gets walked // 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 // 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. // 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 = type RenderItem =
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } } | { 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 }; | { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3; const GROUP_THRESHOLD = 3;
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
for (const tc of m.tool_calls!) { for (const tc of m.tool_calls!) {
const run: ToolRun = { call: tc, result: null }; const run: ToolRun = { call: tc, result: null };
runsByCallId.set(tc.id, run); 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; continue;
} }
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items // 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 // of the same tool name into a single tool_group. Any other render item
// (text bubble, sentinel, user message) breaks the chain. // (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[] { function group(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = []; const out: RenderItem[] = [];
let i = 0; let i = 0;
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
continue; continue;
} }
const name = item.run.call.name; const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1; let j = i + 1;
while ( while (
j < items.length && j < items.length &&
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
) { ) {
j += 1; 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) { if (run.length >= GROUP_THRESHOLD) {
out.push({ out.push({
kind: 'tool_group', kind: 'tool_group',
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
); );
} }
if (item.kind === 'tool_run') { if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />; return <ToolCallLine key={item.key} run={item.run} />;
} }
return <ToolCallGroup key={item.key} runs={item.runs} />; return <ToolCallGroup key={item.key} runs={item.runs} />;