mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-04 07:20:43 +00:00
Compare commits
2 Commits
ci/e2e-nod
...
fix/ai-ass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605105b962 | ||
|
|
35f6ecb128 |
8
.github/workflows/e2eci.yaml
vendored
8
.github/workflows/e2eci.yaml
vendored
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user