mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-29 10:52:26 +00:00
1092 lines
33 KiB
TypeScript
1092 lines
33 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 type { ChatProjectionNode } from '@/lib/projector/chat';
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { EventRenderer } from '@/components/ChatBox/EventTimeline/EventRenderer';
|
|
import { EventTimeline } from '@/components/ChatBox/EventTimeline/EventTimeline';
|
|
import { createChatTimelinePresentationPolicyRegistry } from '@/components/ChatBox/EventTimeline/presentationPolicy';
|
|
import {
|
|
createEventRendererRegistry,
|
|
createEventTypeRendererRegistry,
|
|
} from '@/components/ChatBox/EventTimeline/rendererRegistry';
|
|
|
|
const commonNode = {
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
createdAt: '2026-08-11T10:00:00Z',
|
|
runSequence: 1,
|
|
cloudCursor: null,
|
|
eventType: 'test.event',
|
|
legacyStep: null,
|
|
};
|
|
|
|
function messageNode(
|
|
id: string,
|
|
role: 'user' | 'assistant',
|
|
content: string,
|
|
eventType = 'test.event'
|
|
): ChatProjectionNode {
|
|
return {
|
|
...commonNode,
|
|
id,
|
|
eventId: `event-${id}`,
|
|
eventType,
|
|
kind: 'message',
|
|
role,
|
|
content,
|
|
status: 'complete',
|
|
};
|
|
}
|
|
|
|
function interactionNode(
|
|
id: string,
|
|
status: 'requested' | 'responded' | 'cancelled' | 'expired',
|
|
interactionId: string | undefined,
|
|
overrides: Partial<Extract<ChatProjectionNode, { kind: 'interaction' }>> = {}
|
|
): Extract<ChatProjectionNode, { kind: 'interaction' }> {
|
|
return {
|
|
...commonNode,
|
|
id,
|
|
eventId: `event-${id}`,
|
|
eventType:
|
|
status === 'requested'
|
|
? 'interaction.requested'
|
|
: status === 'expired'
|
|
? 'interaction.expired'
|
|
: status === 'cancelled'
|
|
? 'interaction.cancelled'
|
|
: 'interaction.resolved',
|
|
kind: 'interaction',
|
|
interactionId,
|
|
interactionType: 'choice',
|
|
status,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function correlatedHumanReplyNode(
|
|
id: string,
|
|
content: string,
|
|
interactionId: string,
|
|
runId = 'run-1'
|
|
): Extract<ChatProjectionNode, { kind: 'message' }> {
|
|
return {
|
|
...commonNode,
|
|
id,
|
|
eventId: `event-${id}`,
|
|
eventType: 'legacy.human_reply',
|
|
runId,
|
|
kind: 'message',
|
|
role: 'user',
|
|
content,
|
|
status: 'complete',
|
|
interactionId,
|
|
interactionResponse: true,
|
|
};
|
|
}
|
|
|
|
function activityNode(
|
|
id: string,
|
|
title: string,
|
|
overrides: Partial<Extract<ChatProjectionNode, { kind: 'activity' }>> = {}
|
|
): Extract<ChatProjectionNode, { kind: 'activity' }> {
|
|
return {
|
|
...commonNode,
|
|
id,
|
|
eventId: `event-${id}`,
|
|
eventType: 'tool.completed',
|
|
kind: 'activity',
|
|
activityType: 'tool',
|
|
status: 'completed',
|
|
title,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function noticeNode(id: string, content: string): ChatProjectionNode {
|
|
return {
|
|
...commonNode,
|
|
id,
|
|
eventId: `event-${id}`,
|
|
kind: 'notice',
|
|
severity: 'info',
|
|
content,
|
|
};
|
|
}
|
|
|
|
function unknownNode(): ChatProjectionNode {
|
|
const node = {
|
|
...commonNode,
|
|
id: 'unknown-1',
|
|
eventId: 'event-unknown-1',
|
|
kind: 'unknown',
|
|
eventType: 'future.super_event',
|
|
summary: 'Unsupported future event',
|
|
data: { secret: 'must-not-leak' },
|
|
} as const;
|
|
|
|
return node;
|
|
}
|
|
|
|
function artifactNode(): ChatProjectionNode {
|
|
return {
|
|
...commonNode,
|
|
id: 'artifact-1',
|
|
eventId: 'event-artifact-1',
|
|
eventType: 'artifact.created',
|
|
kind: 'artifact',
|
|
operation: 'created',
|
|
path: '/Users/alice/private-project/report.md',
|
|
name: '/Users/alice/private-project/report.md',
|
|
};
|
|
}
|
|
|
|
describe('EventTimeline', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('renders semantic nodes in timeline order with stable node metadata', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
messageNode('message-1', 'user', 'Start the Run'),
|
|
messageNode('message-2', 'assistant', 'Working on it'),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const timeline = screen.getByRole('list', {
|
|
name: 'Chat event timeline',
|
|
});
|
|
const items = screen.getAllByRole('listitem');
|
|
|
|
expect(timeline).toBeInTheDocument();
|
|
expect(items).toHaveLength(2);
|
|
expect(items[0]).toHaveAttribute('data-event-node-id', 'message-1');
|
|
expect(items[1]).toHaveAttribute('data-event-node-id', 'message-2');
|
|
expect(screen.getByLabelText('Your message')).toHaveTextContent(
|
|
'Start the Run'
|
|
);
|
|
expect(screen.getByLabelText("Eigent's reply")).toHaveTextContent(
|
|
'Working on it'
|
|
);
|
|
expect(screen.getByLabelText('Your message')).toHaveClass('rounded-br-sm');
|
|
expect(screen.getByLabelText('Your message').parentElement).toHaveClass(
|
|
'pl-16'
|
|
);
|
|
expect(screen.getByLabelText("Eigent's reply")).not.toHaveClass(
|
|
'rounded-br-sm'
|
|
);
|
|
});
|
|
|
|
it('presents an explicitly resolved interaction as one traceable input receipt', () => {
|
|
const request = interactionNode(
|
|
'format-request',
|
|
'requested',
|
|
'format-choice',
|
|
{
|
|
prompt: 'Choose output formats',
|
|
options: [
|
|
{ id: 'pdf', label: 'PDF' },
|
|
{ id: 'docx', label: 'Word document' },
|
|
],
|
|
}
|
|
);
|
|
const resolution = interactionNode(
|
|
'format-resolution',
|
|
'responded',
|
|
'format-choice',
|
|
{ responseOptionIds: ['pdf', 'docx'] }
|
|
);
|
|
const sourceNodes = [request, resolution] as const;
|
|
|
|
render(<EventTimeline nodes={sourceNodes} />);
|
|
|
|
const item = screen.getByRole('listitem');
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
expect(item).toHaveAttribute('data-event-node-id', 'format-request');
|
|
expect(item).toHaveAttribute('data-interaction-id', 'format-choice');
|
|
expect(item).toHaveAttribute('data-interaction-status', 'responded');
|
|
expect(item).toHaveAttribute(
|
|
'data-interaction-request-event-id',
|
|
'event-format-request'
|
|
);
|
|
expect(item).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-format-resolution'
|
|
);
|
|
expect(item).toHaveAttribute('data-run-id', 'run-1');
|
|
expect(card).toHaveTextContent('Input required');
|
|
expect(card).toHaveTextContent('Choose output formats');
|
|
expect(card).toHaveTextContent('PDF, Word document');
|
|
expect(card).toHaveTextContent('responded');
|
|
expect(card).toHaveAttribute('data-interaction-id', 'format-choice');
|
|
expect(card).toHaveAttribute('data-interaction-run-id', 'run-1');
|
|
expect(card).toHaveAttribute(
|
|
'data-interaction-request-event-id',
|
|
'event-format-request'
|
|
);
|
|
expect(card).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-format-resolution'
|
|
);
|
|
|
|
// Presentation creates a receipt view; the projector's source ledger is
|
|
// still the original immutable request and resolution pair.
|
|
expect(sourceNodes).toHaveLength(2);
|
|
expect(request.status).toBe('requested');
|
|
expect(request).not.toHaveProperty('resolutionEventId');
|
|
expect(resolution.status).toBe('responded');
|
|
});
|
|
|
|
it('keeps a pending question out of the timeline receipt', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('pending-question', 'requested', 'pending-1', {
|
|
prompt: 'Which dataset should I use while pending?',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(card).toHaveTextContent('Input required');
|
|
expect(card).not.toHaveTextContent(
|
|
'Which dataset should I use while pending?'
|
|
);
|
|
});
|
|
|
|
it('keeps an input receipt at its request position inside later work-log activity', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
activityNode('preparing-agent', 'Preparing agent'),
|
|
activityNode('human-toolkit', 'Human Toolkit'),
|
|
interactionNode('input-request', 'requested', 'input-1', {
|
|
prompt: 'Which dataset should I use?',
|
|
}),
|
|
activityNode('todo-toolkit-one', 'Todo Toolkit: first item'),
|
|
activityNode('todo-toolkit-two', 'Todo Toolkit: second item'),
|
|
interactionNode('input-resolution', 'responded', 'input-1', {
|
|
response: 'Quarterly metrics',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const items = screen.getAllByRole('listitem');
|
|
expect(items.map((item) => item.dataset.eventNodeId)).toEqual([
|
|
'preparing-agent',
|
|
'human-toolkit',
|
|
'input-request',
|
|
'todo-toolkit-one',
|
|
'todo-toolkit-two',
|
|
]);
|
|
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(card).toHaveTextContent('Input required');
|
|
expect(card).toHaveTextContent('Which dataset should I use?');
|
|
expect(card).toHaveTextContent('Quarterly metrics');
|
|
});
|
|
|
|
it('groups consecutive duplicate tool calls behind an optional accordion', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
activityNode('web-fetch-1', 'Fetch first page', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
detail: 'First result',
|
|
}),
|
|
activityNode('web-fetch-2', 'Fetch second page', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
detail: 'Second result',
|
|
}),
|
|
activityNode('web-fetch-3', 'Fetch third page', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
detail: 'Third result',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const group = screen.getByLabelText(
|
|
'Repeated tool calls: WebFetchToolkit · Web_fetch_and_analyze'
|
|
);
|
|
const trigger = screen.getByRole('button', {
|
|
name: /WebFetchToolkit · Web_fetch_and_analyze · 3 events/,
|
|
});
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
expect(group).toHaveAttribute('data-tool-call-count', '3');
|
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
expect(
|
|
screen.queryByText('WebFetchToolkit · Web_fetch_and_analyze')
|
|
).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(trigger);
|
|
|
|
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
expect(
|
|
screen.getAllByText('WebFetchToolkit · Web_fetch_and_analyze')
|
|
).toHaveLength(3);
|
|
expect(screen.getByText('Second result')).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps one tool call as a normal activity row without an accordion', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
activityNode('single-fetch', 'Fetch one page', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Fetch one page')).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByLabelText(/Repeated tool calls:/)
|
|
).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not group matching calls across a chronological boundary', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
activityNode('fetch-before', 'Fetch before notice', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
}),
|
|
noticeNode('fetch-boundary', 'Reviewing fetched sources'),
|
|
activityNode('fetch-after', 'Fetch after notice', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
|
expect(
|
|
screen.queryByLabelText(/Repeated tool calls:/)
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('counts paired lifecycle events as calls rather than event frames', () => {
|
|
const sourceNodes = [
|
|
activityNode('fetch-1-start', 'Fetch first page', {
|
|
eventType: 'tool.started',
|
|
status: 'running',
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
toolCallId: 'fetch-call-1',
|
|
detail: 'First request',
|
|
}),
|
|
activityNode('fetch-1-end', 'Fetch first page', {
|
|
eventType: 'tool.completed',
|
|
status: 'completed',
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
toolCallId: 'fetch-call-1',
|
|
detail: 'First response',
|
|
}),
|
|
activityNode('fetch-2-start', 'Fetch second page', {
|
|
eventType: 'tool.started',
|
|
status: 'running',
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
toolCallId: 'fetch-call-2',
|
|
}),
|
|
activityNode('fetch-2-end', 'Fetch second page', {
|
|
eventType: 'tool.completed',
|
|
status: 'completed',
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
toolCallId: 'fetch-call-2',
|
|
}),
|
|
] as const;
|
|
|
|
render(<EventTimeline nodes={sourceNodes} />);
|
|
|
|
const group = screen.getByLabelText(
|
|
'Repeated tool calls: WebFetchToolkit · Web_fetch_and_analyze'
|
|
);
|
|
expect(group).toHaveAttribute('data-tool-call-count', '2');
|
|
expect(group).toHaveTextContent(
|
|
'WebFetchToolkit · Web_fetch_and_analyze · 2 events'
|
|
);
|
|
expect(sourceNodes).toHaveLength(4);
|
|
expect(sourceNodes[0].status).toBe('running');
|
|
|
|
fireEvent.click(screen.getByRole('button'));
|
|
expect(
|
|
screen.getByText(/First request\s+First response/)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('preserves an open repeated-call accordion as late calls arrive', () => {
|
|
const first = activityNode('late-fetch-1', 'First call', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
});
|
|
const second = activityNode('late-fetch-2', 'Second call', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
});
|
|
const third = activityNode('late-fetch-3', 'Third call', {
|
|
toolkitName: 'WebFetchToolkit',
|
|
methodName: 'Web_fetch_and_analyze',
|
|
});
|
|
const view = render(<EventTimeline nodes={[first, second]} />);
|
|
|
|
fireEvent.click(screen.getByRole('button'));
|
|
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
view.rerender(<EventTimeline nodes={[first, second, third]} />);
|
|
|
|
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
|
|
expect(
|
|
screen.getAllByText('WebFetchToolkit · Web_fetch_and_analyze')
|
|
).toHaveLength(3);
|
|
expect(screen.getByLabelText(/Repeated tool calls:/)).toHaveAttribute(
|
|
'data-tool-call-count',
|
|
'3'
|
|
);
|
|
});
|
|
|
|
it('never merges interaction receipts across ids or runs', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('request-a', 'requested', 'interaction-a', {
|
|
prompt: 'First question',
|
|
runId: 'run-1',
|
|
}),
|
|
interactionNode('resolution-b', 'responded', 'interaction-b', {
|
|
response: 'Answer for another interaction',
|
|
runId: 'run-1',
|
|
}),
|
|
interactionNode(
|
|
'resolution-a-other-run',
|
|
'responded',
|
|
'interaction-a',
|
|
{ response: 'Answer from another run', runId: 'run-2' }
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const cards = screen.getAllByLabelText('Agent request');
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
|
expect(cards.map((card) => card.dataset.interactionStatus)).toEqual([
|
|
'requested',
|
|
'responded',
|
|
'responded',
|
|
]);
|
|
expect(cards[0]).toHaveAttribute('data-interaction-run-id', 'run-1');
|
|
expect(cards[2]).toHaveAttribute('data-interaction-run-id', 'run-2');
|
|
});
|
|
|
|
it('updates an expired request in place instead of leaving two stale boxes', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('expired-request', 'requested', 'expired-1', {
|
|
prompt: 'Choose a format',
|
|
}),
|
|
interactionNode('expired-receipt', 'expired', 'expired-1'),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
expect(card).toHaveTextContent('Input required');
|
|
expect(card).not.toHaveTextContent('Choose a format');
|
|
expect(card).toHaveAttribute('data-interaction-status', 'expired');
|
|
expect(card).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-expired-receipt'
|
|
);
|
|
});
|
|
|
|
it('folds an explicitly correlated legacy human reply into its request', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('continue-request', 'requested', 'continue-1', {
|
|
prompt: 'Continue with the report?',
|
|
}),
|
|
correlatedHumanReplyNode('continue-reply', 'Yes', 'continue-1'),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
expect(card).toHaveTextContent('Input required');
|
|
expect(card).toHaveTextContent('Continue with the report?');
|
|
expect(card).toHaveTextContent('Yes');
|
|
expect(card).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-continue-reply'
|
|
);
|
|
});
|
|
|
|
it('prefers a canonical pending request over one legacy ASK mirror', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode(
|
|
'pending-legacy-request',
|
|
'requested',
|
|
'pending-dual-request',
|
|
{ eventType: 'legacy.ask', legacyStep: 'ask' }
|
|
),
|
|
interactionNode(
|
|
'pending-canonical-request',
|
|
'requested',
|
|
'pending-dual-request',
|
|
{ legacyStep: 'ask' }
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const item = screen.getByRole('listitem');
|
|
expect(item).toHaveAttribute(
|
|
'data-event-node-id',
|
|
'pending-canonical-request'
|
|
);
|
|
expect(screen.getAllByLabelText('Agent request')).toHaveLength(1);
|
|
});
|
|
|
|
it('collapses canonical and legacy dual writes into one canonical-anchored receipt', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode(
|
|
'full-legacy-request',
|
|
'requested',
|
|
'full-dual-write',
|
|
{
|
|
eventType: 'legacy.ask',
|
|
legacyStep: 'ask',
|
|
prompt: 'Which source should I use?',
|
|
}
|
|
),
|
|
interactionNode(
|
|
'full-canonical-request',
|
|
'requested',
|
|
'full-dual-write',
|
|
{ prompt: 'Which source should I use?' }
|
|
),
|
|
interactionNode(
|
|
'full-canonical-resolution',
|
|
'responded',
|
|
'full-dual-write',
|
|
{ response: 'Use the finance workbook' }
|
|
),
|
|
correlatedHumanReplyNode(
|
|
'full-legacy-resolution',
|
|
'Use the finance workbook',
|
|
'full-dual-write'
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const item = screen.getByRole('listitem');
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(item).toHaveAttribute(
|
|
'data-event-node-id',
|
|
'full-canonical-request'
|
|
);
|
|
expect(item).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-full-canonical-resolution'
|
|
);
|
|
expect(card).toHaveTextContent('Use the finance workbook');
|
|
expect(screen.queryByLabelText('Your message')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('fails closed when a request has multiple legacy ASK mirrors', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode(
|
|
'ambiguous-canonical-request',
|
|
'requested',
|
|
'ambiguous-request'
|
|
),
|
|
interactionNode(
|
|
'ambiguous-legacy-request-one',
|
|
'requested',
|
|
'ambiguous-request',
|
|
{ eventType: 'legacy.ask', legacyStep: 'ask' }
|
|
),
|
|
interactionNode(
|
|
'ambiguous-legacy-request-two',
|
|
'requested',
|
|
'ambiguous-request',
|
|
{ eventType: 'legacy.ask', legacyStep: 'ask' }
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
|
expect(screen.getAllByLabelText('Agent request')).toHaveLength(3);
|
|
});
|
|
|
|
it('prefers one canonical receipt and suppresses its equal legacy mirror', () => {
|
|
const sourceNodes = [
|
|
interactionNode('dual-request', 'requested', 'dual-write-1'),
|
|
interactionNode('dual-canonical', 'responded', 'dual-write-1', {
|
|
response: 'Use quarterly metrics',
|
|
}),
|
|
correlatedHumanReplyNode(
|
|
'dual-legacy-mirror',
|
|
'Use quarterly metrics',
|
|
'dual-write-1'
|
|
),
|
|
] as const;
|
|
|
|
render(<EventTimeline nodes={sourceNodes} />);
|
|
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
expect(card).toHaveTextContent('Use quarterly metrics');
|
|
expect(card).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-dual-canonical'
|
|
);
|
|
expect(screen.queryByLabelText('Your message')).not.toBeInTheDocument();
|
|
expect(sourceNodes).toHaveLength(3);
|
|
});
|
|
|
|
it('uses one explicit legacy mirror when the canonical receipt omits display text', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('missing-copy-request', 'requested', 'missing-copy'),
|
|
interactionNode(
|
|
'missing-copy-canonical',
|
|
'responded',
|
|
'missing-copy'
|
|
),
|
|
correlatedHumanReplyNode(
|
|
'missing-copy-mirror',
|
|
'Use the finance workbook',
|
|
'missing-copy'
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const card = screen.getByLabelText('Agent request');
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
expect(card).toHaveTextContent('Use the finance workbook');
|
|
expect(card).toHaveAttribute(
|
|
'data-interaction-resolution-event-id',
|
|
'event-missing-copy-canonical'
|
|
);
|
|
});
|
|
|
|
it('fails closed when a legacy mirror disagrees with the canonical answer', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('conflict-request', 'requested', 'conflict-1'),
|
|
interactionNode('conflict-canonical', 'responded', 'conflict-1', {
|
|
response: 'Use dataset A',
|
|
}),
|
|
correlatedHumanReplyNode(
|
|
'conflict-mirror',
|
|
'Use dataset B',
|
|
'conflict-1'
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
|
expect(screen.getAllByLabelText('Agent request')).toHaveLength(2);
|
|
expect(screen.getByLabelText('Your message')).toHaveTextContent(
|
|
'Use dataset B'
|
|
);
|
|
});
|
|
|
|
it('fails closed when canonical terminal receipts are duplicated', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode(
|
|
'duplicate-terminal-request',
|
|
'requested',
|
|
'duplicate-terminal'
|
|
),
|
|
interactionNode(
|
|
'duplicate-terminal-one',
|
|
'responded',
|
|
'duplicate-terminal',
|
|
{ response: 'First answer' }
|
|
),
|
|
interactionNode(
|
|
'duplicate-terminal-two',
|
|
'responded',
|
|
'duplicate-terminal',
|
|
{ response: 'Second answer' }
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
|
expect(screen.getAllByLabelText('Agent request')).toHaveLength(3);
|
|
});
|
|
|
|
it('keeps an uncorrelated human reply separate instead of guessing by adjacency', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('continue-request', 'requested', 'continue-1', {
|
|
prompt: 'Continue with the report?',
|
|
}),
|
|
messageNode('nearby-reply', 'user', 'Yes', 'legacy.human_reply'),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
|
expect(screen.getByLabelText('Agent request')).toHaveAttribute(
|
|
'data-interaction-status',
|
|
'requested'
|
|
);
|
|
expect(screen.getByLabelText('Your message')).toHaveTextContent('Yes');
|
|
});
|
|
|
|
it('fails closed when an interaction id has ambiguous duplicate receipts', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
interactionNode('request-one', 'requested', 'duplicate-id', {
|
|
prompt: 'Question one',
|
|
}),
|
|
interactionNode('request-two', 'requested', 'duplicate-id', {
|
|
prompt: 'Question two',
|
|
}),
|
|
interactionNode('resolution', 'responded', 'duplicate-id', {
|
|
response: 'Ambiguous answer',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
|
expect(screen.getAllByLabelText('Agent request')).toHaveLength(3);
|
|
});
|
|
|
|
it('allows a product renderer to override one semantic node kind', () => {
|
|
const registry = createEventRendererRegistry({
|
|
message: ({ node }) => (
|
|
<div data-testid="custom-message">Custom: {node.content}</div>
|
|
),
|
|
});
|
|
|
|
render(
|
|
<EventTimeline
|
|
nodes={[messageNode('message-1', 'assistant', 'Projected output')]}
|
|
registry={registry}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('custom-message')).toHaveTextContent(
|
|
'Custom: Projected output'
|
|
);
|
|
});
|
|
|
|
it('selects related renderers from semantic node kinds', () => {
|
|
const registry = createEventRendererRegistry({
|
|
message: ({ node }) => (
|
|
<div data-testid="message-kind-renderer">{node.content}</div>
|
|
),
|
|
notice: ({ node }) => (
|
|
<div data-testid="notice-kind-renderer">{node.content}</div>
|
|
),
|
|
});
|
|
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
messageNode('message-1', 'assistant', 'Message event'),
|
|
noticeNode('notice-1', 'Notice event'),
|
|
]}
|
|
registry={registry}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('message-kind-renderer')).toHaveTextContent(
|
|
'Message event'
|
|
);
|
|
expect(screen.getByTestId('notice-kind-renderer')).toHaveTextContent(
|
|
'Notice event'
|
|
);
|
|
});
|
|
|
|
it('allows an exact event type to override its semantic kind renderer', () => {
|
|
const eventTypeRegistry = createEventTypeRendererRegistry({
|
|
'message.answer.completed': ({ node }) => (
|
|
<div data-testid="event-type-renderer">
|
|
{node.kind === 'message' ? node.content : node.eventType}
|
|
</div>
|
|
),
|
|
});
|
|
|
|
render(
|
|
<EventTimeline
|
|
eventTypeRegistry={eventTypeRegistry}
|
|
nodes={[
|
|
messageNode(
|
|
'message-special',
|
|
'assistant',
|
|
'Special answer',
|
|
'message.answer.completed'
|
|
),
|
|
messageNode(
|
|
'message-default',
|
|
'assistant',
|
|
'Ordinary answer',
|
|
'message.completed'
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('event-type-renderer')).toHaveTextContent(
|
|
'Special answer'
|
|
);
|
|
expect(screen.getByLabelText("Eigent's reply")).toHaveTextContent(
|
|
'Ordinary answer'
|
|
);
|
|
});
|
|
|
|
it('does not resolve inherited object properties as event type renderers', () => {
|
|
render(
|
|
<EventTimeline
|
|
eventTypeRegistry={createEventTypeRendererRegistry()}
|
|
nodes={[
|
|
messageNode(
|
|
'message-1',
|
|
'assistant',
|
|
'Prototype-safe answer',
|
|
'toString'
|
|
),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByLabelText("Eigent's reply")).toHaveTextContent(
|
|
'Prototype-safe answer'
|
|
);
|
|
});
|
|
|
|
it('uses the narrative presentation policy for the narrative mode', () => {
|
|
render(
|
|
<EventTimeline
|
|
detailLevel="narrative"
|
|
nodes={[
|
|
messageNode('message-1', 'assistant', 'Normal response'),
|
|
activityNode('activity-1', 'Routine tool call', {
|
|
detail: '{"internal":"payload"}',
|
|
}),
|
|
unknownNode(),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
const timeline = screen.getByRole('list', {
|
|
name: 'Chat event timeline',
|
|
});
|
|
expect(timeline).toHaveAttribute(
|
|
'data-requested-detail-level',
|
|
'narrative'
|
|
);
|
|
expect(timeline).toHaveAttribute(
|
|
'data-effective-detail-level',
|
|
'narrative'
|
|
);
|
|
expect(screen.getByLabelText("Eigent's reply")).toHaveTextContent(
|
|
'Normal response'
|
|
);
|
|
expect(screen.getByText('Routine tool call')).toBeInTheDocument();
|
|
expect(screen.queryByText('{"internal":"payload"}')).toBeNull();
|
|
expect(screen.queryByText('future.super_event')).toBeNull();
|
|
});
|
|
|
|
it('applies a registered detail-level presentation policy', () => {
|
|
const presentationPolicies = createChatTimelinePresentationPolicyRegistry({
|
|
narrative: (nodes) => nodes.filter((node) => node.kind === 'notice'),
|
|
});
|
|
|
|
render(
|
|
<EventTimeline
|
|
detailLevel="narrative"
|
|
nodes={[
|
|
messageNode('message-1', 'assistant', 'Hidden by narrative policy'),
|
|
noticeNode('notice-1', 'Compact milestone'),
|
|
]}
|
|
presentationPolicies={presentationPolicies}
|
|
/>
|
|
);
|
|
|
|
const timeline = screen.getByRole('list', {
|
|
name: 'Chat event timeline',
|
|
});
|
|
expect(timeline).toHaveAttribute(
|
|
'data-effective-detail-level',
|
|
'narrative'
|
|
);
|
|
expect(screen.queryByText('Hidden by narrative policy')).toBeNull();
|
|
expect(screen.getByText('Compact milestone')).toBeInTheDocument();
|
|
});
|
|
|
|
it('falls back safely when a presentation policy fails', () => {
|
|
const presentationPolicies = createChatTimelinePresentationPolicyRegistry({
|
|
narrative: () => {
|
|
throw new Error('narrative policy failed');
|
|
},
|
|
});
|
|
|
|
render(
|
|
<EventTimeline
|
|
detailLevel="narrative"
|
|
nodes={[messageNode('message-1', 'assistant', 'Preserved output')]}
|
|
presentationPolicies={presentationPolicies}
|
|
/>
|
|
);
|
|
|
|
const timeline = screen.getByRole('list', {
|
|
name: 'Chat event timeline',
|
|
});
|
|
expect(timeline).toHaveAttribute(
|
|
'data-effective-detail-level',
|
|
'trajectory'
|
|
);
|
|
expect(screen.getByLabelText("Eigent's reply")).toHaveTextContent(
|
|
'Preserved output'
|
|
);
|
|
});
|
|
|
|
it('does not expose unknown event payloads in its fallback', () => {
|
|
render(<EventTimeline nodes={[unknownNode()]} />);
|
|
|
|
const fallback = screen.getByRole('status', {
|
|
name: 'Unsupported message',
|
|
});
|
|
expect(fallback).toHaveAttribute(
|
|
'data-event-fallback',
|
|
'unsupported-event'
|
|
);
|
|
expect(fallback).toHaveTextContent('future.super_event');
|
|
expect(fallback).not.toHaveTextContent('must-not-leak');
|
|
});
|
|
|
|
it('warns that an unknown tool outcome must not be retried automatically', () => {
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
activityNode('unknown-outcome', 'Send external message', {
|
|
eventType: 'tool.outcome_unknown',
|
|
status: 'outcome_unknown',
|
|
}),
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
'The external action may have already happened'
|
|
);
|
|
expect(screen.getByText('outcome unknown')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders only a safe artifact name without exposing its full path', () => {
|
|
render(<EventTimeline nodes={[artifactNode()]} />);
|
|
|
|
const label = screen.getByText('report.md');
|
|
expect(label).not.toHaveAttribute('title');
|
|
expect(
|
|
screen.queryByText('/Users/alice/private-project/report.md')
|
|
).toBeNull();
|
|
});
|
|
|
|
it('uses the safe fallback when no renderer is registered', () => {
|
|
render(<EventRenderer node={unknownNode()} registry={{}} />);
|
|
|
|
expect(
|
|
screen.getByRole('status', { name: 'Unsupported message' })
|
|
).toHaveAttribute('data-event-fallback', 'missing-renderer');
|
|
});
|
|
|
|
it('isolates a renderer failure and continues rendering later nodes', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const onRendererError = vi.fn();
|
|
const registry = createEventRendererRegistry({
|
|
message: () => {
|
|
throw new Error('renderer exploded');
|
|
},
|
|
});
|
|
|
|
render(
|
|
<EventTimeline
|
|
nodes={[
|
|
messageNode('message-1', 'assistant', 'Broken renderer'),
|
|
noticeNode('notice-1', 'Run is still healthy'),
|
|
]}
|
|
onRendererError={onRendererError}
|
|
registry={registry}
|
|
/>
|
|
);
|
|
|
|
expect(
|
|
screen.getByRole('status', { name: 'Unsupported message' })
|
|
).toHaveAttribute('data-event-fallback', 'renderer-error');
|
|
expect(screen.getByText('Run is still healthy')).toBeInTheDocument();
|
|
expect(onRendererError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: 'renderer exploded' }),
|
|
{ nodeId: 'message-1', nodeKind: 'message' },
|
|
expect.objectContaining({ componentStack: expect.any(String) })
|
|
);
|
|
});
|
|
|
|
it('renders a caller-owned empty state without creating an empty list', () => {
|
|
render(
|
|
<EventTimeline emptyState={<p>No projected events yet.</p>} nodes={[]} />
|
|
);
|
|
|
|
expect(screen.getByText('No projected events yet.')).toBeInTheDocument();
|
|
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
|
});
|
|
});
|