mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
d6f0e48a67
commit
054cd67139
5 changed files with 144 additions and 4 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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')] });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue