From 054cd67139aff9259914e46f5253eca7c9c781ef Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 7 Jul 2026 08:48:42 -0400 Subject: [PATCH] fix: single question UI per pause + immediate answer display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-turn issues with the new durable Q&A card: 1. Duplicate question on ask: during a live pause the message carries BOTH the ask tool_call part (now rendered by AskUserQuestionCall, showing a misleading 'No answer was given' while paused) and the synthetic interactive card. The durable card now defers while the turn is live and unanswered (isSubmitting) — the interactive card owns the question UI until it's answered; an abandoned pause still shows its no-answer state once the turn settles. 2. 'No answer was given' after answering: the server stamps the answer onto the part at resume seed, but the client only received that at finalize. No stream emission needed — the client knows the answer it just submitted: resolveAskUserQuestionPart (replacing the plain strip on submit success) removes the synthetic card AND stamps output/progress onto the newest unanswered ask tool_call, seeding args from the synthetic part's question when the streamed args were lost — mirroring the server-side attachAskUserQuestionAnswer, so the Q&A record shows the answer the moment the user submits. --- .../Chat/Messages/Content/ApprovalContext.tsx | 8 +-- .../Messages/Content/AskUserQuestionCall.tsx | 14 ++++ .../components/Chat/Messages/Content/Part.tsx | 1 + client/src/utils/approval.spec.ts | 55 +++++++++++++++ client/src/utils/approval.ts | 70 +++++++++++++++++++ 5 files changed, 144 insertions(+), 4 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx index 32bf59429f..86ad42deec 100644 --- a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx +++ b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx @@ -6,7 +6,7 @@ import { useSubmitAskAnswerMutation, type ResumeAgentFields, } from '~/data-provider'; -import { removeAskUserQuestionPart } from '~/utils/approval'; +import { resolveAskUserQuestionPart } from '~/utils/approval'; import { ChatContext } from '~/Providers/ChatContext'; import { useGetEphemeralAgent } from '~/store/agents'; @@ -296,11 +296,11 @@ export function useResumeSubmit() { if (messages && chatContext?.setMessages) { let changed = false; const next = messages.map((message) => { - const stripped = removeAskUserQuestionPart(message, actionId); - if (stripped !== message) { + const resolved = resolveAskUserQuestionPart(message, actionId, answer); + if (resolved !== message) { changed = true; } - return stripped; + return resolved; }); if (changed) { chatContext.setMessages(next); diff --git a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx index bd4aba5439..ec1bd296e4 100644 --- a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx +++ b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx @@ -12,14 +12,28 @@ import { useLocalize } from '~/hooks'; export default function AskUserQuestionCall({ args, output, + isSubmitting = false, }: { args: string | Record | undefined; output: string; + isSubmitting?: boolean; }) { const localize = useLocalize(); const question = parseAskUserQuestionArgs(args); const answered = output.length > 0; + /** + * While the turn is live and unanswered, the INTERACTIVE card (rendered from + * the pendingAction's synthetic part) owns the question UI — rendering the + * durable record too would duplicate it with a misleading "no answer" line. + * Once the user answers, the submit handler stamps `output` onto this part, + * so the record takes over immediately; an abandoned pause only shows its + * "no answer" state after the turn is no longer submitting. + */ + if (!answered && isSubmitting) { + return null; + } + /** Prefer the picked option's label over its wire value when they differ. */ const answerLabel = question?.options?.find((option) => option.value === output)?.label ?? output; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 33969ec56d..34e7c2a9f4 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -204,6 +204,7 @@ const Part = memo(function Part({ ); } else if (toolCall.name === 'skill') { diff --git a/client/src/utils/approval.spec.ts b/client/src/utils/approval.spec.ts index f1d2dd2ca2..6d20820d03 100644 --- a/client/src/utils/approval.spec.ts +++ b/client/src/utils/approval.spec.ts @@ -8,6 +8,7 @@ import { findPendingActionMessageIndex, removeAskUserQuestionPart, parseAskUserQuestionArgs, + resolveAskUserQuestionPart, } from './approval'; const toolCallPart = (id: string, extra: Record = {}): TMessageContentParts => @@ -269,6 +270,60 @@ describe('removeAskUserQuestionPart', () => { }); }); +describe('resolveAskUserQuestionPart', () => { + const withCardAndToolCall = () => { + const base = msg({ + content: [ + textPart('pre-pause'), + { + type: 'tool_call', + tool_call: { id: 'tc1', name: 'ask_user_question', args: '', type: 'tool_call' }, + } as unknown as TMessageContentParts, + ], + }); + return applyPendingAction(base, askAction()); + }; + + it('strips the card and stamps the answer (seeding args from the synthetic question)', () => { + const resolved = resolveAskUserQuestionPart(withCardAndToolCall(), 'a1', 'green'); + const content = resolved.content as Array<{ + type?: string; + tool_call?: Record; + }>; + expect(content.some((part) => part?.type === 'ask_user_question')).toBe(false); + const toolCall = content[1]?.tool_call as Record; + expect(toolCall.output).toBe('green'); + expect(toolCall.progress).toBe(1); + expect(JSON.parse(toolCall.args as string)).toMatchObject({ question: 'What name?' }); + }); + + it('keeps streamed args when the part already has them', () => { + const base = msg({ + content: [ + { + type: 'tool_call', + tool_call: { + id: 'tc1', + name: 'ask_user_question', + args: '{"question":"streamed"}', + type: 'tool_call', + }, + } as unknown as TMessageContentParts, + ], + }); + const resolved = resolveAskUserQuestionPart(applyPendingAction(base, askAction()), 'a1', 'x'); + const toolCall = (resolved.content as Array<{ tool_call?: Record }>)[0] + ?.tool_call as Record; + expect(toolCall.args).toBe('{"question":"streamed"}'); + expect(toolCall.output).toBe('x'); + }); + + it('returns the same reference when the message has no matching synthetic part', () => { + const plain = msg({ content: [textPart('hi')] }); + expect(resolveAskUserQuestionPart(plain, 'a1', 'x')).toBe(plain); + }); +}); + describe('applyPendingAction — unsupported type', () => { it('returns the original message unchanged', () => { const message = msg({ content: [textPart('hi')] }); diff --git a/client/src/utils/approval.ts b/client/src/utils/approval.ts index 88f0768b8c..5bfad846cb 100644 --- a/client/src/utils/approval.ts +++ b/client/src/utils/approval.ts @@ -234,6 +234,76 @@ export function removeAskUserQuestionPart(message: TMessage, actionId: string): return { ...message, content: nextContent }; } +/** + * Resolve an answered ask-user-question pause on the client, mirroring the + * server's resume-time stamp so the durable Q&A card shows the answer the + * moment the user submits (the server-patched part otherwise only arrives at + * finalize): removes the synthetic card part for `actionId` AND patches the + * newest unanswered `ask_user_question` tool_call with `output = answer` + * (seeding `args` from the synthetic part's question when the streamed args + * were lost). Pure — returns the input message when nothing matched. + */ +export function resolveAskUserQuestionPart( + message: TMessage, + actionId: string, + answer: string, +): TMessage { + const content = message.content; + if (!Array.isArray(content)) { + return message; + } + const syntheticPart = content.find( + (part) => + isAskUserQuestionPart(part) && + (part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION].actionId === actionId, + ) as unknown as AskUserQuestionPart | undefined; + if (!syntheticPart) { + return message; + } + + let patched = false; + const nextContent: TMessageContentParts[] = []; + for (const part of content) { + if ( + isAskUserQuestionPart(part) && + (part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION].actionId === actionId + ) { + continue; // strip the pause-scoped card + } + nextContent.push(part); + } + for (let i = nextContent.length - 1; i >= 0; i--) { + const part = nextContent[i] as { type?: string; tool_call?: Agents.ToolCall } | undefined; + const toolCall = part?.tool_call; + if ( + part?.type !== ContentTypes.TOOL_CALL || + toolCall?.name !== ASK_USER_QUESTION || + (typeof toolCall.output === 'string' && toolCall.output.length > 0) + ) { + continue; + } + const hasArgs = + (typeof toolCall.args === 'string' && toolCall.args.trim().length > 0) || + (toolCall.args != null && typeof toolCall.args === 'object'); + nextContent[i] = { + ...(part as object), + tool_call: { + ...toolCall, + ...(hasArgs ? {} : { args: JSON.stringify(syntheticPart[ASK_USER_QUESTION].question) }), + output: answer, + progress: 1, + }, + } as TMessageContentParts; + patched = true; + break; + } + + if (!patched && nextContent.length === content.length) { + return message; + } + return { ...message, content: nextContent }; +} + /** * Applies a {@link Agents.PendingAction} onto the target response message, * dispatching on the interrupt type. Pure — returns a new message only when the