diff --git a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx index ec1bd296e4..972ace253c 100644 --- a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx +++ b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx @@ -1,5 +1,5 @@ import { MessageCircleQuestion } from 'lucide-react'; -import { parseAskUserQuestionArgs } from '~/utils/approval'; +import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval'; import { useLocalize } from '~/hooks'; /** @@ -12,15 +12,24 @@ import { useLocalize } from '~/hooks'; export default function AskUserQuestionCall({ args, output, + toolCallId, isSubmitting = false, }: { args: string | Record | undefined; output: string; + toolCallId?: string; isSubmitting?: boolean; }) { const localize = useLocalize(); const question = parseAskUserQuestionArgs(args); - const answered = output.length > 0; + /** + * The part's own output arrives from the server only at finalize, and the + * streaming handler's message copy can overwrite the optimistic store stamp + * mid-stream — fall back to the locally-recorded submitted answer so the + * Q&A record never blinks out while the resumed segment streams. + */ + const effectiveOutput = output.length > 0 ? output : (getSubmittedAskAnswer(toolCallId) ?? ''); + const answered = effectiveOutput.length > 0; /** * While the turn is live and unanswered, the INTERACTIVE card (rendered from @@ -35,7 +44,8 @@ export default function AskUserQuestionCall({ } /** Prefer the picked option's label over its wire value when they differ. */ - const answerLabel = question?.options?.find((option) => option.value === output)?.label ?? output; + const answerLabel = + question?.options?.find((option) => option.value === effectiveOutput)?.label ?? effectiveOutput; return (
diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 34e7c2a9f4..9173d8c896 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({ ); diff --git a/client/src/utils/approval.spec.ts b/client/src/utils/approval.spec.ts index 6d20820d03..fc99e7f013 100644 --- a/client/src/utils/approval.spec.ts +++ b/client/src/utils/approval.spec.ts @@ -9,6 +9,7 @@ import { removeAskUserQuestionPart, parseAskUserQuestionArgs, resolveAskUserQuestionPart, + getSubmittedAskAnswer, } from './approval'; const toolCallPart = (id: string, extra: Record = {}): TMessageContentParts => @@ -322,6 +323,13 @@ describe('resolveAskUserQuestionPart', () => { const plain = msg({ content: [textPart('hi')] }); expect(resolveAskUserQuestionPart(plain, 'a1', 'x')).toBe(plain); }); + + it('records the submitted answer by tool_call id (render-layer fallback for mid-stream copies)', () => { + resolveAskUserQuestionPart(withCardAndToolCall(), 'a1', 'purple'); + expect(getSubmittedAskAnswer('tc1')).toBe('purple'); + expect(getSubmittedAskAnswer('unknown')).toBeUndefined(); + expect(getSubmittedAskAnswer(undefined)).toBeUndefined(); + }); }); describe('applyPendingAction — unsupported type', () => { diff --git a/client/src/utils/approval.ts b/client/src/utils/approval.ts index 5bfad846cb..83d757f21a 100644 --- a/client/src/utils/approval.ts +++ b/client/src/utils/approval.ts @@ -234,6 +234,21 @@ export function removeAskUserQuestionPart(message: TMessage, actionId: string): return { ...message, content: nextContent }; } +/** + * Session-scoped record of answers the user has submitted, keyed by the ask + * tool_call id. Render-layer fallback for `AskUserQuestionCall`: the SSE + * step handler evolves its own cached copy of the streaming message, so a + * store-level `output` stamp can be overwritten by the next streamed event — + * this survives any message-copy churn until finalize delivers the + * server-stamped part. Written by {@link resolveAskUserQuestionPart}. + */ +const submittedAskAnswers = new Map(); + +/** The locally-submitted answer for an ask tool_call, if any. */ +export function getSubmittedAskAnswer(toolCallId: string | undefined): string | undefined { + return toolCallId ? submittedAskAnswers.get(toolCallId) : undefined; +} + /** * 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 @@ -294,6 +309,9 @@ export function resolveAskUserQuestionPart( progress: 1, }, } as TMessageContentParts; + if (typeof toolCall.id === 'string' && toolCall.id.length > 0) { + submittedAskAnswers.set(toolCall.id, answer); + } patched = true; break; }