eigent/test/unit/lib/chatTimelinePresentation.test.ts
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

498 lines
15 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 {
composeTimelineRun,
composeTimelineRuns,
reconcileTimelineRun,
reconcileTimelineRuns,
} from '@/lib/projector/chat/presentation';
import type {
ChatActivityNode,
ChatArtifactNode,
ChatInteractionNode,
ChatMessageNode,
ChatPlanNode,
ChatProjectionNodeBase,
ChatRunStatusNode,
} from '@/lib/projector/chat/types';
import type { ProjectedRun } from '@/lib/projector/types';
import { describe, expect, it } from 'vitest';
function base(
id: string,
runId: string,
runSequence: number,
createdAt:
| string
| null = `2026-08-19T00:00:${String(runSequence).padStart(2, '0')}.000Z`,
cloudCursor: number | null = runSequence
): ChatProjectionNodeBase {
return {
id,
eventId: id,
projectId: 'project-1',
runId,
createdAt,
runSequence,
cloudCursor,
eventType: `test.${id}`,
legacyStep: null,
};
}
function message(
id: string,
runId: string,
runSequence: number,
overrides: Partial<ChatMessageNode> = {}
): ChatMessageNode {
return {
...base(id, runId, runSequence),
kind: 'message',
role: 'assistant',
content: id,
status: 'complete',
purpose: 'narration',
...overrides,
};
}
function tool(
id: string,
runId: string,
runSequence: number,
overrides: Partial<ChatActivityNode> = {}
): ChatActivityNode {
return {
...base(id, runId, runSequence),
kind: 'activity',
activityType: 'tool',
status: 'running',
phase: 'started',
title: 'Search Toolkit.search',
toolkitName: 'Search Toolkit',
methodName: 'search',
...overrides,
};
}
describe('event-native Timeline Run presentation', () => {
it('groups Runs and orders their nodes deterministically without mutating input', () => {
const runTwoFinal = message('run-2-final', 'run-2', 2, {
purpose: 'final',
content: 'Second result',
cloudCursor: 22,
});
const runOneFinal = message('run-1-final', 'run-1', 3, {
purpose: 'final',
content: 'First result',
cloudCursor: 13,
});
const runOneQuery = message('run-1-query', 'run-1', 1, {
role: 'user',
purpose: 'query',
content: 'First query',
cloudCursor: 11,
});
const runTwoQuery = message('run-2-query', 'run-2', 1, {
role: 'user',
purpose: 'query',
content: 'Second query',
cloudCursor: 21,
});
const input = [runTwoFinal, runOneFinal, runOneQuery, runTwoQuery] as const;
const originalOrder = input.map((node) => node.id);
const runs = composeTimelineRuns(input);
expect(runs.map((run) => run.runId)).toEqual(['run-1', 'run-2']);
expect(runs[0]?.nodes.map((node) => node.id)).toEqual([
'run-1-query',
'run-1-final',
]);
expect(runs[1]?.nodes.map((node) => node.id)).toEqual([
'run-2-query',
'run-2-final',
]);
expect(runs[0]?.id).toBe('timeline-run:run-1');
expect(runs[0]?.userQuery?.content).toBe('First query');
expect(runs[0]?.finalAssistantResponse?.content).toBe('First result');
expect(input.map((node) => node.id)).toEqual(originalOrder);
});
it('exposes plans, interactions, artifacts, terminal status, and Run timestamps', () => {
const plan: ChatPlanNode = {
...base('plan', 'run-1', 3),
kind: 'plan',
status: 'active',
title: 'Plan',
tasks: [],
};
const interaction: ChatInteractionNode = {
...base('interaction', 'run-1', 4),
kind: 'interaction',
interactionId: 'interaction-1',
interactionType: 'question',
status: 'requested',
prompt: 'Which tone?',
};
const artifact: ChatArtifactNode = {
...base('artifact', 'run-1', 5),
kind: 'artifact',
operation: 'updated',
path: 'src/index.ts',
relativePath: 'src/index.ts',
};
const started: ChatRunStatusNode = {
...base('started', 'run-1', 1, '2026-08-19T00:00:01.000Z'),
kind: 'run_status',
status: 'running',
};
const completed: ChatRunStatusNode = {
...base('completed', 'run-1', 7, '2026-08-19T00:00:11.000Z'),
kind: 'run_status',
status: 'completed',
};
const query = message('query', 'run-1', 2, {
role: 'user',
purpose: 'query',
content: 'Implement it',
});
const final = message('final', 'run-1', 6, {
purpose: 'final',
content: 'Implemented',
});
const run = composeTimelineRun(
[completed, final, artifact, interaction, plan, query, started],
'run-1'
);
expect(run).not.toBeNull();
expect(run?.plans).toEqual([plan]);
expect(run?.interactions).toEqual([interaction]);
expect(run?.artifacts).toEqual([artifact]);
expect(run?.runStatus).toBe(completed);
expect(run?.status).toBe('completed');
expect(run?.timestamps).toEqual({
createdAt: '2026-08-19T00:00:01.000Z',
startedAt: '2026-08-19T00:00:01.000Z',
updatedAt: '2026-08-19T00:00:11.000Z',
endedAt: '2026-08-19T00:00:11.000Z',
durationMs: 10_000,
totalAttemptElapsedMs: null,
elapsedAnchor: null,
});
});
it('pairs non-adjacent tool lifecycle receipts by toolCallId with stable safe fields', () => {
const started = tool('tool-started', 'run-1', 2, {
toolCallId: 'call-1',
detail: 'safe request fallback',
createdAt: '2026-08-19T00:00:02.000Z',
});
const narration = message('narration', 'run-1', 3, {
content: 'Searching now',
});
const completed = tool('tool-completed', 'run-1', 4, {
toolCallId: 'call-1',
status: 'completed',
phase: 'completed',
detail: 'safe response fallback',
createdAt: '2026-08-19T00:00:07.000Z',
});
const first = composeTimelineRun([completed, narration, started], 'run-1');
const second = composeTimelineRun([started, completed, narration], 'run-1');
const toolRow = first?.traceRows.find((row) => row.kind === 'tool');
expect(toolRow?.kind).toBe('tool');
if (toolRow?.kind !== 'tool') throw new Error('Expected a tool row');
expect(toolRow.id).toBe('tool-call:run-1:call-1');
expect(toolRow.invocation.nodes.map((node) => node.id)).toEqual([
'tool-started',
'tool-completed',
]);
expect(toolRow.invocation.input).toBe('safe request fallback');
expect(toolRow.invocation.output).toBe('safe response fallback');
expect(toolRow.invocation.detail).toBeUndefined();
expect(toolRow.invocation.status).toBe('completed');
expect(toolRow.invocation.durationMs).toBe(5_000);
expect(first?.summary.toolCallCount).toBe(1);
expect(second?.traceRows.map((row) => row.id)).toEqual(
first?.traceRows.map((row) => row.id)
);
});
it('does not guess lifecycle correlation when a toolCallId is absent', () => {
const started = tool('anonymous-started', 'run-1', 1);
const completed = tool('anonymous-completed', 'run-1', 2, {
status: 'completed',
phase: 'completed',
});
const run = composeTimelineRun([started, completed], 'run-1');
expect(run?.traceRows.map((row) => row.id)).toEqual([
'tool-event:anonymous-started',
'tool-event:anonymous-completed',
]);
expect(run?.summary.toolCallCount).toBe(2);
});
it('pairs legacy toolkit receipts FIFO while preserving stable call rows', () => {
const firstStarted = tool('first-started', 'run-1', 1, {
eventType: 'legacy.activate_toolkit',
legacyStep: 'activate_toolkit',
input: 'first request',
});
const secondStarted = tool('second-started', 'run-1', 2, {
eventType: 'legacy.activate_toolkit',
legacyStep: 'activate_toolkit',
input: 'second request',
});
const firstCompleted = tool('first-completed', 'run-1', 3, {
eventType: 'legacy.deactivate_toolkit',
legacyStep: 'deactivate_toolkit',
status: 'completed',
phase: 'completed',
output: 'first response',
});
const secondCompleted = tool('second-completed', 'run-1', 4, {
eventType: 'legacy.deactivate_toolkit',
legacyStep: 'deactivate_toolkit',
status: 'completed',
phase: 'completed',
output: 'second response',
});
const run = composeTimelineRun(
[secondCompleted, firstStarted, firstCompleted, secondStarted],
'run-1'
);
const calls = run?.traceRows.flatMap((row) =>
row.kind === 'tool' ? [row.invocation] : []
);
expect(calls?.map((call) => call.id)).toEqual([
'legacy-tool-call:run-1:first-started',
'legacy-tool-call:run-1:second-started',
]);
expect(calls?.map((call) => call.nodes.map((node) => node.id))).toEqual([
['first-started', 'first-completed'],
['second-started', 'second-completed'],
]);
expect(calls?.map((call) => [call.input, call.output])).toEqual([
['first request', 'first response'],
['second request', 'second response'],
]);
expect(run?.summary.toolCallCount).toBe(2);
});
it('presents terminal activity as an invocation with vertical input/output data', () => {
const terminal = tool('terminal-1', 'run-1', 1, {
activityType: 'terminal',
eventType: 'legacy.terminal',
legacyStep: 'terminal',
title: 'Terminal',
input: 'npm test',
output: '6 tests passed',
status: 'completed',
phase: 'completed',
toolkitName: undefined,
methodName: undefined,
});
const run = composeTimelineRun([terminal], 'run-1');
const row = run?.traceRows[0];
expect(row?.kind).toBe('tool');
if (row?.kind !== 'tool') throw new Error('Expected invocation row');
expect(row.invocation).toMatchObject({
id: 'tool-event:terminal-1',
activityType: 'terminal',
input: 'npm test',
output: '6 tests passed',
});
});
it('uses unique semantic identities for summarised counts', () => {
const nodes = [
tool('tool-start', 'run-1', 1, { toolCallId: 'tool-1' }),
tool('tool-end', 'run-1', 2, {
toolCallId: 'tool-1',
status: 'completed',
phase: 'completed',
}),
message('message-created', 'run-1', 3, {
messageId: 'message-1',
status: 'streaming',
}),
message('message-completed', 'run-1', 4, {
messageId: 'message-1',
}),
message('message-2', 'run-1', 5, { purpose: 'final' }),
{
...base('artifact-created', 'run-1', 6),
kind: 'artifact',
operation: 'created',
path: 'src/a.ts',
relativePath: 'src/a.ts',
} satisfies ChatArtifactNode,
{
...base('artifact-updated', 'run-1', 7),
kind: 'artifact',
operation: 'updated',
path: 'src/a.ts',
relativePath: 'src/a.ts',
} satisfies ChatArtifactNode,
{
...base('interaction-requested', 'run-1', 8),
kind: 'interaction',
interactionId: 'interaction-1',
interactionType: 'question',
status: 'requested',
} satisfies ChatInteractionNode,
{
...base('interaction-responded', 'run-1', 9),
kind: 'interaction',
interactionId: 'interaction-1',
interactionType: 'question',
status: 'responded',
} satisfies ChatInteractionNode,
];
const run = composeTimelineRun(nodes, 'run-1');
expect(run?.summary).toEqual({
toolCallCount: 1,
agentMessageCount: 2,
artifactCount: 1,
interactionCount: 1,
});
});
it('returns null when the requested Run has no semantic nodes', () => {
expect(composeTimelineRun([], 'missing')).toBeNull();
});
it('reconciles an active Run with the authoritative status and elapsed anchor', () => {
const final = message('final', 'run-1', 1, {
purpose: 'final',
createdAt: '2026-08-19T00:00:01.000Z',
});
const composed = composeTimelineRun([final], 'run-1')!;
const projected: ProjectedRun = {
runId: 'run-1',
status: 'running',
lastSequence: 8,
runVersion: 2,
updatedAt: '2026-08-19T00:00:10.000Z',
totalAttemptElapsedMs: 7_500,
};
const reconciled = reconcileTimelineRun(composed, projected);
expect(reconciled.status).toBe('running');
expect(reconciled.timestamps).toMatchObject({
updatedAt: '2026-08-19T00:00:10.000Z',
endedAt: null,
durationMs: null,
totalAttemptElapsedMs: 7_500,
elapsedAnchor: {
accumulatedMs: 7_500,
anchoredAt: '2026-08-19T00:00:10.000Z',
},
});
});
it('freezes a terminal Run at the authoritative attempt aggregate', () => {
const running = tool('running', 'run-1', 1, {
createdAt: '2026-08-19T00:00:01.000Z',
});
const composed = composeTimelineRun([running], 'run-1')!;
const projected: ProjectedRun = {
runId: 'run-1',
status: 'completed',
lastSequence: 9,
runVersion: 3,
updatedAt: '2026-08-19T00:00:15.000Z',
totalAttemptElapsedMs: 12_345,
};
const reconciled = reconcileTimelineRun(composed, projected);
expect(reconciled.status).toBe('completed');
expect(reconciled.timestamps).toMatchObject({
updatedAt: '2026-08-19T00:00:15.000Z',
endedAt: '2026-08-19T00:00:15.000Z',
durationMs: 12_345,
totalAttemptElapsedMs: 12_345,
elapsedAnchor: { accumulatedMs: 12_345, anchoredAt: null },
});
});
it('updates the presented Run status row from the authoritative aggregate', () => {
const started: ChatRunStatusNode = {
...base('started', 'run-1', 1, '2026-08-19T00:00:01.000Z'),
kind: 'run_status',
status: 'running',
};
const composed = composeTimelineRun([started], 'run-1')!;
const projected: ProjectedRun = {
runId: 'run-1',
status: 'completed',
lastSequence: 2,
runVersion: 1,
updatedAt: '2026-08-19T00:00:05.000Z',
totalAttemptElapsedMs: 4_000,
};
const reconciled = reconcileTimelineRun(composed, projected);
const statusRow = reconciled.traceRows.find(
(row) => row.kind === 'node' && row.node.kind === 'run_status'
);
expect(reconciled.runStatus?.status).toBe('completed');
expect(reconciled.nodes[0]).toMatchObject({ status: 'completed' });
expect(statusRow).toMatchObject({
kind: 'node',
node: { status: 'completed' },
});
});
it('reconciles a Run collection by id and preserves unmatched Runs', () => {
const first = composeTimelineRun([message('first', 'run-1', 1)], 'run-1')!;
const second = composeTimelineRun(
[message('second', 'run-2', 1)],
'run-2'
)!;
const projected: ProjectedRun = {
runId: 'run-1',
status: 'interrupted',
lastSequence: 2,
runVersion: 1,
updatedAt: '2026-08-19T00:01:00.000Z',
totalAttemptElapsedMs: 2_000,
};
const reconciled = reconcileTimelineRuns([first, second], {
'run-1': projected,
});
expect(reconciled[0]?.status).toBe('interrupted');
expect(reconciled[1]).toBe(second);
});
});