fix: single question UI per pause + immediate answer display

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.
This commit is contained in:
Danny Avila 2026-07-07 08:48:42 -04:00
parent d6f0e48a67
commit 054cd67139
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
5 changed files with 144 additions and 4 deletions

View file

@ -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);

View file

@ -12,14 +12,28 @@ import { useLocalize } from '~/hooks';
export default function AskUserQuestionCall({
args,
output,
isSubmitting = false,
}: {
args: string | Record<string, unknown> | 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;

View file

@ -204,6 +204,7 @@ const Part = memo(function Part({
<AskUserQuestionCall
args={toolCall.args}
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
isSubmitting={isSubmitting}
/>
);
} else if (toolCall.name === 'skill') {

View file

@ -8,6 +8,7 @@ import {
findPendingActionMessageIndex,
removeAskUserQuestionPart,
parseAskUserQuestionArgs,
resolveAskUserQuestionPart,
} from './approval';
const toolCallPart = (id: string, extra: Record<string, unknown> = {}): 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<string, unknown>;
}>;
expect(content.some((part) => part?.type === 'ask_user_question')).toBe(false);
const toolCall = content[1]?.tool_call as Record<string, unknown>;
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<string, unknown> }>)[0]
?.tool_call as Record<string, unknown>;
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')] });

View file

@ -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