🙊 refactor: Clarify Ask Question Schema Errors and Retry Guidance (#14279)

* fix(agents): clarify ask question validation errors

* fix(agents): narrow question failure detection

* fix(agents): persist question validation failures

* fix(agents): track question validation failures
This commit is contained in:
Danny Avila 2026-07-15 11:06:29 -04:00 • committed by GitHub
parent b7542871b9
commit 7447fddfb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 525 additions and 14 deletions

View file

@ -1,4 +1,4 @@
import { MessageCircleQuestion } from 'lucide-react';
import { MessageCircleQuestion, TriangleAlert } from 'lucide-react';
import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval';
import { useLocalize } from '~/hooks';
@ -14,11 +14,13 @@ export default function AskUserQuestionCall({
output,
toolCallId,
isSubmitting = false,
failed = false,
}: {
args: string | Record<string, unknown> | undefined;
output: string;
toolCallId?: string;
isSubmitting?: boolean;
failed?: boolean;
}) {
const localize = useLocalize();
const question = parseAskUserQuestionArgs(args);
@ -29,7 +31,7 @@ export default function AskUserQuestionCall({
* Q&A record never blinks out while the resumed segment streams.
*/
const effectiveOutput = output.length > 0 ? output : (getSubmittedAskAnswer(toolCallId) ?? '');
const answered = effectiveOutput.length > 0;
const answered = effectiveOutput.length > 0 && !failed;
/**
* While the turn is live and unanswered, the INTERACTIVE card (rendered from
@ -39,10 +41,30 @@ export default function AskUserQuestionCall({
* 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) {
if (!answered && !failed && isSubmitting) {
return null;
}
if (failed) {
return (
<div
className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3"
role="status"
>
<div className="flex items-center gap-2 text-xs font-medium text-text-warning">
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_question_failed')}
</div>
{question?.question != null && (
<p className="text-sm font-medium text-text-primary">{question.question}</p>
)}
<p className="text-sm text-text-secondary">
{localize('com_ui_question_failed_description')}
</p>
</div>
);
}
/**
* Prefer the picked option's label over its wire value when they differ.
* Multi-select answers are option values joined by ", " — map the segments

View file

@ -222,6 +222,7 @@ const Part = memo(function Part({
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
toolCallId={toolCall.id}
isSubmitting={isSubmitting}
failed={'inputValidationError' in toolCall && toolCall.inputValidationError === true}
/>
);
} else if (toolCall.name === 'skill') {

View file

@ -0,0 +1,68 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AskUserQuestionCall from '../AskUserQuestionCall';
const translations: Record<string, string> = {
com_ui_asked: 'Asked',
com_ui_asking: 'Asking',
com_ui_question_failed: "Question wasn't shown",
com_ui_question_failed_description:
"The agent couldn't show this question and may retry automatically.",
com_ui_question_unanswered: 'No answer was given',
com_ui_you_answered: 'You answered:',
};
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => translations[key] ?? key,
}));
jest.mock('~/utils/approval', () => ({
getSubmittedAskAnswer: () => undefined,
parseAskUserQuestionArgs: (args: string | Record<string, unknown> | undefined) => {
if (typeof args === 'string') {
return JSON.parse(args) as Record<string, unknown>;
}
return args ?? null;
},
}));
describe('AskUserQuestionCall', () => {
const args = JSON.stringify({
question: 'How would you like me to get the data?',
options: [{ label: 'Use public data', value: 'public' }],
});
test('renders a successful tool result as the user answer', () => {
render(<AskUserQuestionCall args={args} output="public" />);
expect(screen.getByText('You answered:')).toBeInTheDocument();
expect(screen.getByText('Use public data')).toBeInTheDocument();
});
test('renders schema rejection as an internal question failure, not a user answer', () => {
const output =
'Error processing tool: Received tool input did not match expected schema ' +
'✖ String must contain at most 120 character(s) → at options[0].label';
render(<AskUserQuestionCall args={args} output={output} failed />);
expect(screen.getByText("Question wasn't shown")).toBeInTheDocument();
expect(
screen.getByText("The agent couldn't show this question and may retry automatically."),
).toBeInTheDocument();
expect(screen.queryByText('You answered:')).not.toBeInTheDocument();
expect(screen.queryByText(/Received tool input did not match expected schema/)).toBeNull();
});
test('preserves a user answer that contains the complete schema error text', () => {
const output =
'Error processing tool: Received tool input did not match expected schema ' +
'✖ String must contain at most 120 character(s) → at options[0].label';
render(<AskUserQuestionCall args={args} output={output} />);
expect(screen.getByText('You answered:')).toBeInTheDocument();
expect(screen.getByText(output)).toBeInTheDocument();
expect(screen.queryByText("Question wasn't shown")).not.toBeInTheDocument();
});
});

View file

@ -1422,6 +1422,7 @@ describe('useStepHandler', () => {
name: 'test_tool',
args: '{}',
output: 'Tool result output',
inputValidationError: true as const,
type: ToolCallTypes.TOOL_CALL,
},
},
@ -1445,6 +1446,7 @@ describe('useStepHandler', () => {
);
expect(toolCallContent?.tool_call?.output).toBe('Tool result output');
expect(toolCallContent?.tool_call?.progress).toBe(1);
expect(toolCallContent?.tool_call?.inputValidationError).toBe(true);
});
it('signals skill authoring when a completed create_file call targets a skill path', () => {

View file

@ -490,6 +490,12 @@ export default function useStepHandler({
if (finalUpdate) {
newToolCall.progress = 1;
newToolCall.output = contentPart.tool_call.output;
if (
'inputValidationError' in contentPart.tool_call &&
contentPart.tool_call.inputValidationError === true
) {
Object.assign(newToolCall, { inputValidationError: true });
}
}
updatedContent[index] = {

View file

@ -1564,6 +1564,8 @@
"com_ui_provider_api_keys_description": "Manage API keys for endpoints configured to use a user-provided key.",
"com_ui_provider_api_keys_not_set": "No key set",
"com_ui_quality": "Quality",
"com_ui_question_failed": "Question wasn't shown",
"com_ui_question_failed_description": "The agent couldn't show this question and may retry automatically.",
"com_ui_question_unanswered": "No answer was given",
"com_ui_queue": "Queue",
"com_ui_queue_send": "Queue message for after the response",