diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index 2592ad7b39..4c01063571 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -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); } diff --git a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx new file mode 100644 index 0000000000..bd4aba5439 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx @@ -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 | 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 ( +
+
+
+

+ {question?.question ?? localize('com_ui_asked_a_question')} +

+ {question?.description != null && question.description.length > 0 && ( +

{question.description}

+ )} + {answered ? ( +

+ {localize('com_ui_you_answered')}{' '} + {answerLabel} +

+ ) : ( +

+ {localize('com_ui_question_unanswered')} +

+ )} +
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 6998cdbe5f..33969ec56d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -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 ( + + ); } else if (toolCall.name === 'skill') { return ( = {}): 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()); diff --git a/client/src/utils/approval.ts b/client/src/utils/approval.ts index 6e667403fd..88f0768b8c 100644 --- a/client/src/utils/approval.ts +++ b/client/src/utils/approval.ts @@ -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 | 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. diff --git a/packages/api/src/agents/hitl/resume.spec.ts b/packages/api/src/agents/hitl/resume.spec.ts index 044a049d74..3b26e9ac98 100644 --- a/packages/api/src/agents/hitl/resume.spec.ts +++ b/packages/api/src/agents/hitl/resume.spec.ts @@ -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 }).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); + }); +}); diff --git a/packages/api/src/agents/hitl/resume.ts b/packages/api/src/agents/hitl/resume.ts index 01024ca780..4a61a22f85 100644 --- a/packages/api/src/agents/hitl/resume.ts +++ b/packages/api/src/agents/hitl/resume.ts @@ -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; +}