Compare commits

..

2 Commits

Author SHA1 Message Date
Gaurav Tewari
605105b962 test(ai-assistant): cover /clarify rejection 2026-11-04 11:58:02 +05:30
Gaurav Tewari
35f6ecb128 fix(ai-assistant): keep clarification form open when /clarify fails
Retries re-issue /clarify; a dead execution can't be re-attached.
2026-11-04 10:53:46 +05:30
5 changed files with 177 additions and 22 deletions

View File

@@ -47,7 +47,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: lts/*
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -67,7 +67,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shard: [1, 2, 3, 4]
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
@@ -105,7 +105,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: lts/*
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -162,7 +162,7 @@ jobs:
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
- name: teardown-stack
if: always()
run: |

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import {
@@ -56,11 +57,13 @@ export default function ClarificationForm({
);
const [answers, setAnswers] =
useState<Record<string, unknown>>(initialAnswers);
const [submitted, setSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [cancelled, setCancelled] = useState(false);
const setField = (id: string, value: unknown): void => {
setAnswers((prev) => ({ ...prev, [id]: value }));
setSubmitError(null);
};
const isFormValid = fields.every(
@@ -68,10 +71,11 @@ export default function ClarificationForm({
);
const handleSubmit = async (): Promise<void> => {
if (!isFormValid) {
if (!isFormValid || isSubmitting) {
return;
}
setSubmitted(true);
setSubmitError(null);
setIsSubmitting(true);
// Approximate queryLength as the JSON encoding of the form answers — the
// clarification API doesn't render a single user-visible string, but the
// JSON size is a reasonable stand-in for "how much did the user provide".
@@ -84,11 +88,15 @@ export default function ClarificationForm({
hasContext: false,
respondingToClarification: true,
});
await submitClarification(
const error = await submitClarification(
conversationId,
clarification.clarificationId,
answers,
);
if (error) {
setSubmitError(error);
setIsSubmitting(false);
}
};
const handleCancel = (): void => {
@@ -100,15 +108,6 @@ export default function ClarificationForm({
cancelStream(conversationId);
};
if (submitted) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
<Send size={13} className={styles.icon} />
<span className={styles.statusText}>Answers submitted — resuming…</span>
</div>
);
}
if (cancelled) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
@@ -138,6 +137,15 @@ export default function ClarificationForm({
))}
</div>
{submitError && (
<Callout
type="error"
size="small"
title={submitError}
testId="clarification-submit-error"
/>
)}
<div className={styles.actions}>
<Button
disabledTooltip={
@@ -150,6 +158,8 @@ export default function ClarificationForm({
color="primary"
onClick={handleSubmit}
disabled={isStreaming || !isFormValid}
loading={isSubmitting}
testId="clarification-submit"
prefix={<Send />}
>
Submit
@@ -160,7 +170,7 @@ export default function ClarificationForm({
variant="outlined"
color="secondary"
onClick={handleCancel}
disabled={isStreaming}
disabled={isStreaming || isSubmitting}
prefix={<X />}
>
Cancel request

View File

@@ -0,0 +1,50 @@
import {
type ClarificationEventDTO,
ClarificationFieldTypeDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import {
type AIAssistantStore,
useAIAssistantStore,
} from '../../../store/useAIAssistantStore';
import ClarificationForm from '../ClarificationForm';
const VALIDATION_MESSAGE = 'Clarification field threshold must be a number.';
const CLARIFICATION: ClarificationEventDTO = {
executionId: 'exec-1',
clarificationId: 'clar-1',
message: 'A few details needed',
fields: [
{
id: 'threshold',
type: ClarificationFieldTypeDTO.text,
label: 'Threshold',
required: true,
},
],
};
describe('ClarificationForm', () => {
it('keeps the answers and shows the error inline when submitting fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const submitClarification = jest.fn() as jest.MockedFunction<
AIAssistantStore['submitClarification']
>;
submitClarification.mockResolvedValueOnce(VALIDATION_MESSAGE);
useAIAssistantStore.setState({ submitClarification });
render(
<ClarificationForm conversationId="conv-1" clarification={CLARIFICATION} />,
);
await user.type(screen.getByLabelText(/threshold/i), 'five');
await user.click(screen.getByTestId('clarification-submit'));
await expect(
screen.findByTestId('clarification-submit-error'),
).resolves.toHaveTextContent(VALIDATION_MESSAGE);
expect(screen.getByLabelText(/threshold/i)).toHaveValue('five');
expect(screen.getByTestId('clarification-submit')).toBeEnabled();
});
});

View File

@@ -0,0 +1,75 @@
import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios';
import { clarifyExecution, streamEvents } from 'api/ai-assistant/chat';
import {
type ClarificationEventDTO,
ClarificationFieldTypeDTO,
ErrorCodeDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { useAIAssistantStore } from '../useAIAssistantStore';
jest.mock('api/ai-assistant/chat');
const VALIDATION_MESSAGE = 'Clarification field threshold must be a number.';
const CLARIFICATION: ClarificationEventDTO = {
executionId: 'exec-1',
clarificationId: 'clar-1',
message: 'A few details needed',
fields: [
{
id: 'threshold',
type: ClarificationFieldTypeDTO.number,
label: 'Threshold',
required: true,
},
],
};
function validationError(): AxiosError {
const response = {
status: 400,
statusText: 'Bad Request',
headers: {},
config: { headers: new AxiosHeaders() },
data: {
error: { code: ErrorCodeDTO.validation_error, message: VALIDATION_MESSAGE },
},
} as AxiosResponse;
return new AxiosError('Bad Request', '400', undefined, undefined, response);
}
function startAwaitingClarification(): string {
const convId = useAIAssistantStore.getState().startNewConversation();
useAIAssistantStore.setState((s) => {
s.streams[convId] = {
isStreaming: false,
streamingContent: '',
streamingStatus: 'awaiting_clarification',
streamingEvents: [],
streamingMessageId: null,
streamingActions: null,
pendingApproval: null,
pendingClarification: CLARIFICATION,
};
});
return convId;
}
describe('useAIAssistantStore — submitClarification', () => {
it('keeps the form and returns the backend message when /clarify fails', async () => {
jest.mocked(clarifyExecution).mockRejectedValueOnce(validationError());
const convId = startAwaitingClarification();
const error = await useAIAssistantStore
.getState()
.submitClarification(convId, 'clar-1', { threshold: 'abc' });
expect(error).toBe(VALIDATION_MESSAGE);
const { streams, conversations } = useAIAssistantStore.getState();
expect(streams[convId].pendingClarification).toStrictEqual(CLARIFICATION);
expect(streams[convId].isStreaming).toBe(false);
expect(conversations[convId].messages).toHaveLength(0);
expect(streamEvents).not.toHaveBeenCalled();
});
});

View File

@@ -659,11 +659,12 @@ export interface AIAssistantStore {
) => Promise<void>;
approveAction: (conversationId: string, approvalId: string) => Promise<void>;
rejectAction: (conversationId: string, approvalId: string) => Promise<void>;
/** Resolves with an error message when /clarify rejects the answers, which keeps the form open. */
submitClarification: (
conversationId: string,
clarificationId: string,
answers: Record<string, unknown>,
) => Promise<void>;
) => Promise<string | null>;
cancelStream: (conversationId: string) => void;
regenerateAssistantMessage: (
conversationId: string,
@@ -1401,7 +1402,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
conversationId: string,
clarificationId: string,
answers: Record<string, unknown>,
): Promise<void> => {
): Promise<string | null> => {
const fallback =
'Something went wrong while processing your answers. Please try again.';
let acceptedExecutionId: string | null;
try {
acceptedExecutionId = await clarifyExecution(clarificationId, answers);
} catch (err) {
console.error('[AIAssistant] submitClarification failed:', err);
return resolveAssistantError(err, fallback).message;
}
set((s) => {
const st = s.streams[conversationId];
if (st) {
@@ -1413,17 +1424,26 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
// Streams the accepted execution first; retries re-issue /clarify for a fresh one.
const start = (): Promise<string> => {
const id = acceptedExecutionId;
acceptedExecutionId = null;
return id
? Promise.resolve(id)
: clarifyExecution(clarificationId, answers);
};
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => clarifyExecution(clarificationId, answers),
'Something went wrong while processing your answers. Please try again.',
start,
fallback,
'[AIAssistant] submitClarification failed:',
set,
get,
run,
);
await run();
return null;
},
retryAssistantMessage: async (conversationId: string): Promise<void> => {