// ========= 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(); 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( ); 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( ); 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( ); 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(); 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(); 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(); 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( ); 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( ); 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( ); 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( ); 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(); const persistentButton = screen.getByRole('button', { name: 'Always allow', }); expect(persistentButton).toBeDisabled(); await waitFor(() => expect(persistentButton).toBeEnabled()); expect(isHumanInteractionStillPending).toHaveBeenCalledWith( toolInteraction ); }); });