eigent/test/unit/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx
Douglas Lai 88f530358e
Some checks are pending
Pre-commit / pre-commit (push) Waiting to run
Test / Run Web + Local Brain Smoke (push) Waiting to run
Test / Run Frontend Guardrails (push) Waiting to run
Test / Run Python Tests (push) Waiting to run
Redesign two chat timeline view (#1856)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-20 23:48:54 +08:00

354 lines
12 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
HumanInteractionCard,
isHumanInteractionReadOnly,
} from '@/components/ChatBox/MessageItem/HumanInteractionCard';
import {
isHumanInteractionStillPending,
type HumanInteractionPayload,
} from '@/service/humanInteractionApi';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
decideHumanInteraction: vi.fn(),
isHumanInteractionStillPending: vi.fn(),
toastError: vi.fn(),
}));
vi.mock('@/service/humanInteractionApi', async (importOriginal) => {
const original =
await importOriginal<typeof import('@/service/humanInteractionApi')>();
return {
...original,
decideHumanInteraction: mocks.decideHumanInteraction,
isHumanInteractionStillPending: mocks.isHumanInteractionStillPending,
};
});
vi.mock('@/store/authStore', () => ({
getAuthStore: () => ({
language: 'en-US',
setLanguage: vi.fn(),
}),
useAuthStore: (selector: (state: { user_id: number }) => unknown): unknown =>
selector({ user_id: 42 }),
}));
vi.mock('sonner', () => ({
toast: { error: mocks.toastError },
}));
const approvalInteraction: HumanInteractionPayload = {
interaction_id: 'approval-1',
interaction_type: 'approval' as const,
run_id: 'run-1',
version: 0,
action_digest: 'a'.repeat(64),
title: 'Allow todo_write?',
question: 'The agent wants to run todo_write.',
allowed_scopes: ['once' as const],
};
const interaction = approvalInteraction;
describe('HumanInteractionCard', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.decideHumanInteraction.mockResolvedValue({ status: 'resolved' });
mocks.isHumanInteractionStillPending.mockResolvedValue(false);
});
it('keeps a waiting durable approval actionable after replay reattachment', async () => {
const readOnly = isHumanInteractionReadOnly({
interaction: approvalInteraction,
activeTaskId: 'run-1',
taskType: 'replay',
taskStatus: 'finished',
durableRunStatus: 'waiting_for_user',
});
expect(readOnly).toBe(false);
const onResolved = vi.fn();
render(
<HumanInteractionCard
interaction={approvalInteraction}
readOnly={readOnly}
onResolved={onResolved}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Approve once' }));
await waitFor(() =>
expect(mocks.decideHumanInteraction).toHaveBeenCalledWith(
approvalInteraction,
expect.objectContaining({
decision: { decision: 'approved', scope: 'once' },
actorId: 42,
})
)
);
expect(onResolved).toHaveBeenCalledWith('Approved once');
expect(screen.queryByText('Allow todo_write?')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Approve once' })
).not.toBeInTheDocument();
});
it('keeps terminal replay history read-only', async () => {
const readOnly = isHumanInteractionReadOnly({
interaction: approvalInteraction,
activeTaskId: 'run-1',
taskType: 'replay',
taskStatus: 'finished',
durableRunStatus: 'completed',
});
render(
<HumanInteractionCard
interaction={approvalInteraction}
readOnly={readOnly}
/>
);
await waitFor(() =>
expect(isHumanInteractionStillPending).toHaveBeenCalledWith(interaction)
);
expect(screen.getByRole('button', { name: 'Approve once' })).toBeDisabled();
});
it('keeps a current pending approval actionable while legacy replay flags are stale', () => {
const readOnly = isHumanInteractionReadOnly({
interaction,
activeTaskId: 'run-1',
taskType: 'replay',
taskStatus: 'finished',
durableRunStatus: undefined,
});
expect(readOnly).toBe(false);
});
it('renders an approval timeline receipt as only Input required', () => {
const { container } = render(
<HumanInteractionCard
interaction={approvalInteraction}
response="Approved once"
timelineReceipt
/>
);
const receipt = container.querySelector('[data-approval-timeline-receipt]');
expect(receipt).toHaveTextContent(/^Input required$/);
expect(screen.queryByText(approvalInteraction.question!)).toBeNull();
expect(screen.queryByText('Your response')).toBeNull();
expect(screen.queryByText('Approved once')).toBeNull();
expect(screen.queryByText('Decision saved')).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
});
it('offers a Space-scoped approval for an exact opaque tool matcher', async () => {
const toolInteraction: HumanInteractionPayload = {
...approvalInteraction,
// Legacy cards deliberately hide Run scope because a Project can contain
// multiple Runs; event-native BottomBox owns Run-scoped decisions.
allowed_scopes: ['once', 'run', 'space'] as const,
rule_matcher: {
action_pattern: 'action-identity:sha256:opaque-digest',
display_operation: 'mcp.tool.write',
resource_pattern: 'tool-identity:sha256:abc',
matcher_kind: 'literal_tool',
},
};
render(<HumanInteractionCard interaction={toolInteraction} />);
expect(
screen.queryByRole('button', { name: 'Allow for this Run' })
).not.toBeInTheDocument();
expect(screen.getByText(/mcp\.tool\.write/)).toBeInTheDocument();
expect(
screen.queryByText(/action-identity:sha256/)
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', {
name: 'Always allow',
})
);
await waitFor(() =>
expect(mocks.decideHumanInteraction).toHaveBeenCalledWith(
toolInteraction,
expect.objectContaining({
decision: { decision: 'approved', scope: 'space' },
})
)
);
});
it('shows a durable API rejection inline and re-enables retry', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
mocks.decideHumanInteraction.mockRejectedValueOnce(
new Error('Approval version changed')
);
render(<HumanInteractionCard interaction={approvalInteraction} />);
fireEvent.click(screen.getByRole('button', { name: 'Approve once' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Approval version changed'
);
expect(mocks.toastError).toHaveBeenCalledWith('Approval version changed');
expect(screen.getByRole('button', { name: 'Approve once' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Approve once' }));
await waitFor(() => {
expect(mocks.decideHumanInteraction).toHaveBeenCalledTimes(2);
});
consoleError.mockRestore();
});
it('only renders persistent approval actions offered by the backend', () => {
render(<HumanInteractionCard interaction={approvalInteraction} />);
expect(
screen.queryByRole('button', { name: 'Allow for this Run' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Always allow in Space' })
).not.toBeInTheDocument();
});
it('renders a resolved question and its composer answer as one card', () => {
render(
<HumanInteractionCard
interaction={{
interaction_id: 'question-1',
interaction_type: 'question',
run_id: 'run-1',
title: 'Input required',
question: 'Which market should I use?',
}}
response="The UK market"
/>
);
expect(screen.getByText('Which market should I use?')).toBeInTheDocument();
expect(screen.getByText('Your response')).toBeInTheDocument();
expect(screen.getByText('The UK market')).toBeInTheDocument();
expect(
screen.getByText('The UK market').closest('[data-interaction-response]')
).toBeInTheDocument();
});
it('renders the question and answer together in a timeline receipt', () => {
render(
<HumanInteractionCard
interaction={{
interaction_id: 'choice-timeline',
interaction_type: 'choice',
run_id: 'run-1',
question: 'Choose a private deployment region',
options: [{ option_id: 'uk', label: 'United Kingdom' }],
}}
response="United Kingdom"
timelineReceipt
/>
);
expect(screen.getByText('Input required')).toBeInTheDocument();
expect(
screen.getByText('Choose a private deployment region')
).toBeInTheDocument();
expect(screen.getByText('United Kingdom')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'United Kingdom' })
).not.toBeInTheDocument();
});
it('keeps the question out of a pending timeline receipt', () => {
render(
<HumanInteractionCard
interaction={{
interaction_id: 'choice-pending',
interaction_type: 'choice',
run_id: 'run-1',
question: 'Choose a pending deployment region',
options: [{ option_id: 'uk', label: 'United Kingdom' }],
}}
timelineReceipt
/>
);
expect(screen.getByText('Input required')).toBeInTheDocument();
expect(
screen.queryByText('Choose a pending deployment region')
).not.toBeInTheDocument();
});
it('shows the selected choice label after submitting a timeline receipt', async () => {
const onResolved = vi.fn();
render(
<HumanInteractionCard
interaction={{
interaction_id: 'choice-1',
interaction_type: 'choice',
run_id: 'run-1',
question: 'Pick one',
options: [{ option_id: 'option-a', label: 'Option A', value: 'a' }],
}}
onResolved={onResolved}
timelineReceipt
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Option A' }));
await waitFor(() => {
expect(screen.getByText('Your response')).toBeInTheDocument();
});
expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Option A' })).toBeNull();
expect(onResolved).toHaveBeenCalledWith('Option A');
});
it('unlocks a legacy Project-keyed card only after Brain confirms it is still pending', async () => {
vi.mocked(isHumanInteractionStillPending).mockResolvedValueOnce(true);
const toolInteraction: HumanInteractionPayload = {
...interaction,
allowed_scopes: ['once', 'space'],
rule_matcher: {
action_pattern: 'action-identity:sha256:opaque-digest',
display_operation: 'mcp.tool.write',
resource_pattern: 'tool-identity:sha256:abc',
matcher_kind: 'literal_tool',
},
};
render(<HumanInteractionCard interaction={toolInteraction} readOnly />);
const persistentButton = screen.getByRole('button', {
name: 'Always allow',
});
expect(persistentButton).toBeDisabled();
await waitFor(() => expect(persistentButton).toBeEnabled());
expect(isHumanInteractionStillPending).toHaveBeenCalledWith(
toolInteraction
);
});
});