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>
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user