amruthpillai/reactive-resume · error · ORPCError
BAD_REQUEST
BAD_REQUEST
Error message
No matching unanswered user question was found.
What it means
Thrown by mergeAskUserQuestionOutputs when the incoming assistant message contains one or more 'answered' tool-ask_user_question parts (state output-available or output-error) but NONE of their toolCallIds match an existing unanswered (state input-available) tool-ask_user_question part in the persisted assistant message. The didMerge flag stays false, so the server refuses to write a no-op update.
Source
Thrown at packages/api/src/features/agent/service.ts:247
didMerge = true;
if (answeredPart.state === "output-error") {
return {
...part,
state: "output-error",
errorText: answeredPart.errorText ?? "User answer failed.",
} as UIMessage["parts"][number];
}
return {
...part,
state: "output-available",
output: answeredPart.output,
} as UIMessage["parts"][number];
});
if (!didMerge) {
throw new ORPCError("BAD_REQUEST", { message: "No matching unanswered user question was found." });
}
return { ...existingMessage, parts };
}
function getFirstUnansweredAskUserQuestionToolCallId(message: UIMessage) {
const part = message.parts.find((part) => {
const toolPart = part as AgentToolPart;
return (
toolPart.type === "tool-ask_user_question" &&
typeof toolPart.toolCallId === "string" &&
toolPart.state === "input-available"
);
}) as AgentToolPart | undefined;
return part?.toolCallId;
}
View on GitHub (pinned to 3a5b12e2a4)
Solutions
- On the client, only submit answers using toolCallIds harvested from the latest assistant message parts that are still in state 'input-available'.
- Make the submit-answer call idempotent: ignore a 400 'No matching unanswered user question' if the local UI already shows the question as answered.
- Refresh the thread's messages before re-submitting so the client sees the current part states.
- Confirm the message.id being sent matches the assistant message id returned by the server.
Example fix
// before: client invents / reuses a toolCallId
const toolCallId = lastSubmittedId ?? makeId();
await orpc.agent.messages.send({ message: { role: 'assistant', parts: [{ type: 'tool-ask_user_question', toolCallId, state: 'output-available', output: answer }] } });
// after: read the unanswered part from the live assistant message
const unanswered = assistantMsg.parts.find(p => p.type === 'tool-ask_user_question' && p.state === 'input-available');
if (!unanswered) return; // nothing to answer
await orpc.agent.messages.send({ message: { role: 'assistant', parts: [{ type: 'tool-ask_user_question', toolCallId: unanswered.toolCallId, state: 'output-available', output: answer }] } }); Defensive patterns
Strategy: validation
Validate before calling
const unanswered = assistantMsg.parts.find(
(p): p is Extract<typeof p, { type: 'tool-ask_user_question' }> =>
p.type === 'tool-ask_user_question' && (p as any).state === 'input-available' && typeof (p as any).toolCallId === 'string',
);
if (!unanswered) return; // nothing to submit Type guard
function isUnansweredAskUserQuestionPart(part: any): boolean {
return part?.type === 'tool-ask_user_question' && part?.state === 'input-available' && typeof part?.toolCallId === 'string';
} Try / catch
try {
await orpc.agent.messages.send({ threadId, message });
} catch (err) {
if (err instanceof ORPCError && err.code === 'BAD_REQUEST' && /no matching unanswered/.test(err.message.toLowerCase())) {
// Already answered or stale; refresh messages and move on.
await refreshThread();
return;
}
throw err;
} Prevention
- Drive submit only from the live assistant message parts, never from cached IDs.
- Single-flight the submit-answer button so retries cannot race.
- After a successful submit, mark the part as answered locally to prevent re-submit.
When it happens
Trigger: Client sends an assistant-role message whose ask_user_question toolCallId was already answered; client fabricated a toolCallId that was never issued by the assistant; the underlying assistant message was rewritten/re-streamed so the original input-available part is gone; answering a question from a different thread/message.
Common situations: Frontend retries a 'submit answer' call after the first succeeded (idempotency not handled); stale UI state after the thread was re-streamed; bug in client that synthesizes a toolCallId instead of reading it from the assistant part; double-submit from a flaky network.
Related errors
AI-assisted analysis of amruthpillai/reactive-resume@3a5b12e2a4 (2026-08-12).
Data as JSON: /api/errors/897f65095e8db77d.
Report an issue: GitHub.