feat: dedicated UI + durable data for completed ask_user_question calls

The completed ask call rendered as a generic tool card labeled 'Cancelled'
with raw (and empty) JSON args. Two layers fixed:

Data: the saved tool_call part had args:'' and no output — streamed arg
chunks carry no tool name so the aggregator drops them (normal tools recover
via the completion event, which never fires for a tool that interrupts
mid-execution and resumes on a rebuilt run with no step id). The resume
controller now stamps the paused ask part with the pendingAction's
authoritative question as args and the user's answer as output
(attachAskUserQuestionAnswer — pure, targets the newest unanswered ask part,
so sequential questions each keep their own answer).

UI: Part.tsx routes ask_user_question tool calls to AskUserQuestionCall — a
compact Q&A record ('Asked a question' header, question, description, 'You
answered: <label>' preferring the picked option's label, or 'No answer was
given' for an abandoned pause) instead of the generic card. New i18n keys;
parseAskUserQuestionArgs degrades to null on malformed model args.
This commit is contained in:
Danny Avila 2026-07-07 08:39:54 -04:00
parent 88a254ee84
commit d6f0e48a67
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
8 changed files with 212 additions and 1 deletions

View file

@ -5,6 +5,7 @@ const {
isPendingActionStale,
mapToolApprovalResolutions,
mapAskUserAnswer,
attachAskUserQuestionAnswer,
findUndecidedToolCalls,
findDisallowedDecisions,
findIncompleteDecisions,
@ -612,7 +613,19 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
// in-memory store's setContentParts REPLACES the stored array, so reading the
// resume state afterward would see the new (empty) client array and lose the seed.
const resumeState = await GenerationJobManager.getResumeState(streamId);
const seedContent = resumeState?.aggregatedContent ?? [];
let seedContent = resumeState?.aggregatedContent ?? [];
// Stamp the answered question onto the paused ask_user_question tool-call part
// (args = the pendingAction's authoritative question, output = the user's answer):
// the streamed arg chunks carry no tool name so the aggregator dropped them, and
// no completion event ever fires for this tool — without this the saved part is
// an empty "cancelled-looking" tool call. See attachAskUserQuestionAnswer.
if (pendingAction.payload?.type === 'ask_user_question') {
seedContent = attachAskUserQuestionAnswer(
seedContent,
pendingAction.payload.question,
req.body.answer,
);
}
if (client.contentParts) {
GenerationJobManager.setContentParts(streamId, client.contentParts);
}

View file

@ -0,0 +1,50 @@
import { MessageCircleQuestion } from 'lucide-react';
import { parseAskUserQuestionArgs } from '~/utils/approval';
import { useLocalize } from '~/hooks';
/**
* Static rendering of a COMPLETED (or abandoned) `ask_user_question` tool call
* the durable record of the Q&A after the pause resolves. The generic tool card
* is wrong here: it labels a no-output call "cancelled" and shows raw JSON args.
* The interactive card ({@link AskUserQuestion}) renders only while the pause is
* live; this component owns the part everywhere else (history, reload, exports).
*/
export default function AskUserQuestionCall({
args,
output,
}: {
args: string | Record<string, unknown> | undefined;
output: string;
}) {
const localize = useLocalize();
const question = parseAskUserQuestionArgs(args);
const answered = output.length > 0;
/** Prefer the picked option's label over its wire value when they differ. */
const answerLabel = question?.options?.find((option) => option.value === output)?.label ?? output;
return (
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
<div className="flex items-center gap-2 text-xs font-medium text-text-secondary">
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_asked_a_question')}
</div>
<p className="text-sm font-medium text-text-primary">
{question?.question ?? localize('com_ui_asked_a_question')}
</p>
{question?.description != null && question.description.length > 0 && (
<p className="text-sm text-text-secondary">{question.description}</p>
)}
{answered ? (
<p className="text-sm text-text-primary">
<span className="font-medium text-text-secondary">{localize('com_ui_you_answered')}</span>{' '}
{answerLabel}
</p>
) : (
<p className="text-sm italic text-text-secondary">
{localize('com_ui_question_unanswered')}
</p>
)}
</div>
);
}

View file

@ -23,6 +23,7 @@ import {
SubagentCall,
} from './Parts';
import { getAskUserQuestionPart } from '~/utils/approval';
import AskUserQuestionCall from './AskUserQuestionCall';
import { isBashProgrammaticToolCall } from './routing';
import { ErrorMessage } from './MessageContent';
import AskUserQuestion from './AskUserQuestion';
@ -196,6 +197,15 @@ const Part = memo(function Part({
hideAttachments={hideAttachments}
/>
);
} else if (toolCall.name === 'ask_user_question') {
/** Dedicated Q&A record the generic tool card would label the
* interrupt-resolved call "cancelled" and dump raw JSON args. */
return (
<AskUserQuestionCall
args={toolCall.args}
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
/>
);
} else if (toolCall.name === 'skill') {
return (
<SkillCall

View file

@ -856,6 +856,7 @@
"com_ui_artifacts_options": "Artifacts Options",
"com_ui_artifacts_subtext": "Lets the agent render React, HTML, SVG, Markdown, and Mermaid as interactive artifacts in a side panel instead of plain code blocks.",
"com_ui_ascending": "Asc",
"com_ui_asked_a_question": "Asked a question",
"com_ui_assistant": "Assistant",
"com_ui_assistant_delete_error": "There was an error deleting the assistant",
"com_ui_assistant_deleted": "Successfully deleted assistant",
@ -1528,6 +1529,7 @@
"com_ui_quality": "Quality",
"com_ui_quote_selections": "{{0}} selections",
"com_ui_quotes_queued": "Quotes added for your next message",
"com_ui_question_unanswered": "No answer was given",
"com_ui_ran_n_agents": "Ran {{0}} agents",
"com_ui_read_aloud": "Read aloud",
"com_ui_read_file": "Read {{0}}",
@ -2008,6 +2010,7 @@
"com_ui_x_selected": "{{0}} selected",
"com_ui_xhigh": "Extra High",
"com_ui_yes": "Yes",
"com_ui_you_answered": "You answered:",
"com_ui_you": "You",
"com_ui_your_answer": "Type your answer…",
"com_ui_your_api_key": "Your API Key",

View file

@ -7,6 +7,7 @@ import {
getAskUserQuestionPart,
findPendingActionMessageIndex,
removeAskUserQuestionPart,
parseAskUserQuestionArgs,
} from './approval';
const toolCallPart = (id: string, extra: Record<string, unknown> = {}): TMessageContentParts =>
@ -226,6 +227,27 @@ describe('applyPendingAction — ask_user_question', () => {
});
});
describe('parseAskUserQuestionArgs', () => {
it('parses a JSON-string args payload (the persisted wire shape)', () => {
const parsed = parseAskUserQuestionArgs(
JSON.stringify({ question: 'Which?', options: [{ label: 'A', value: 'a' }] }),
);
expect(parsed?.question).toBe('Which?');
expect(parsed?.options).toHaveLength(1);
});
it('accepts an already-parsed object', () => {
expect(parseAskUserQuestionArgs({ question: 'Which?' })?.question).toBe('Which?');
});
it('degrades to null on empty, malformed, or question-less args', () => {
expect(parseAskUserQuestionArgs('')).toBeNull();
expect(parseAskUserQuestionArgs('not json')).toBeNull();
expect(parseAskUserQuestionArgs('{"no_question": true}')).toBeNull();
expect(parseAskUserQuestionArgs(undefined)).toBeNull();
});
});
describe('removeAskUserQuestionPart', () => {
it('strips the synthetic part for the matching actionId and keeps everything else', () => {
const withCard = applyPendingAction(msg({ content: [textPart('hello')] }), askAction());

View file

@ -176,6 +176,36 @@ function applyAskUserQuestion(
return { ...message, content: [...content, askPart] };
}
/**
* Parse an `ask_user_question` tool call's args into the question request shape.
* Args arrive as a JSON string on persisted messages (or an object mid-stream);
* malformed/empty args degrade to `null` so the caller can render a fallback
* label instead of crashing on model output.
*/
export function parseAskUserQuestionArgs(
args: string | Record<string, unknown> | undefined,
): Agents.AskUserQuestionRequest | null {
let parsed: unknown = args;
if (typeof args === 'string') {
if (args.trim().length === 0) {
return null;
}
try {
parsed = JSON.parse(args);
} catch {
return null;
}
}
if (
parsed == null ||
typeof parsed !== 'object' ||
typeof (parsed as { question?: unknown }).question !== 'string'
) {
return null;
}
return parsed as unknown as Agents.AskUserQuestionRequest;
}
/**
* Removes the synthetic ask-user-question part for `actionId` from a message.
* Pure returns the same message reference when nothing matched.

View file

@ -6,6 +6,7 @@ import {
findDisallowedDecisions,
findIncompleteDecisions,
createContentIndexOffsetHandlers,
attachAskUserQuestionAnswer,
} from './resume';
describe('mapToolApprovalResolutions', () => {
@ -224,3 +225,40 @@ describe('createContentIndexOffsetHandlers', () => {
expect(calls[0].data).toBe(weird);
});
});
describe('attachAskUserQuestionAnswer', () => {
const question = { question: 'Which env?', options: [{ label: 'Staging', value: 'staging' }] };
const askPart = (output?: string) => ({
type: 'tool_call',
tool_call: {
id: 'tc1',
name: 'ask_user_question',
args: '',
...(output != null && { output }),
},
});
it('stamps args (the authoritative question) and output (the answer) onto the last unanswered ask part', () => {
const content = [{ type: 'text' } as never, askPart()];
const next = attachAskUserQuestionAnswer(content as never, question as never, 'staging');
expect(next).not.toBe(content);
const patched = (next[1] as { tool_call: Record<string, unknown> }).tool_call;
expect(patched.output).toBe('staging');
expect(patched.progress).toBe(1);
expect(JSON.parse(patched.args as string)).toEqual(question);
// original untouched (pure)
expect((content[1] as { tool_call: { output?: string } }).tool_call.output).toBeUndefined();
});
it('skips already-answered ask parts and targets the newest unanswered one', () => {
const content = [askPart('earlier answer'), askPart()];
const next = attachAskUserQuestionAnswer(content as never, question as never, 'blue');
expect((next[0] as { tool_call: { output: string } }).tool_call.output).toBe('earlier answer');
expect((next[1] as { tool_call: { output: string } }).tool_call.output).toBe('blue');
});
it('returns the input array untouched when no ask part matches', () => {
const content = [{ type: 'text' } as never, askPart('done')];
expect(attachAskUserQuestionAnswer(content as never, question as never, 'x')).toBe(content);
});
});

View file

@ -6,6 +6,7 @@ import type {
EventHandler,
} from '@librechat/agents';
import type { Agents } from 'librechat-data-provider';
import { ASK_USER_QUESTION_TOOL_NAME } from './askUserQuestionTool';
/**
* Translate the host-facing approval wire format into the SDK's resume value.
@ -183,3 +184,47 @@ export function createContentIndexOffsetHandlers(
return wrapped;
}
/**
* Stamp the answered question onto the paused `ask_user_question` tool-call part
* before the resume run seeds it back into the content pipeline.
*
* WHY the part is otherwise empty: the streamed arg CHUNKS carry no tool name, and
* the aggregator only accepts name-less arg updates on the completion event which
* never fires for this tool (the first pass interrupts mid-execution, and the
* rebuilt resume run has no step id to complete against). Saved messages therefore
* showed `args: ""` and no `output`, and the client rendered a "cancelled" tool.
* The authoritative data exists anyway: the pendingAction payload carries the full
* question, and the resume request carries the user's answer.
*
* Patches the LAST unanswered ask part (a re-pause targets the newest question;
* earlier ones already carry their answers). Pure returns the input array when
* nothing matched.
*/
export function attachAskUserQuestionAnswer<
TPart extends { type?: string; tool_call?: { name?: string; output?: unknown } },
>(content: TPart[], question: Agents.AskUserQuestionRequest, answer: string): TPart[] {
for (let i = content.length - 1; i >= 0; i--) {
const part = content[i];
const toolCall = part?.tool_call;
if (
part?.type !== 'tool_call' ||
toolCall?.name !== ASK_USER_QUESTION_TOOL_NAME ||
(typeof toolCall.output === 'string' && toolCall.output.length > 0)
) {
continue;
}
const next = [...content];
next[i] = {
...part,
tool_call: {
...toolCall,
args: JSON.stringify(question),
output: answer,
progress: 1,
},
};
return next;
}
return content;
}