mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-27 09:31:56 +00:00
440 lines
13 KiB
TypeScript
440 lines
13 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 { presentChatSemanticEntities } from '@/components/ChatBox/EventTimeline/presentationPolicy';
|
|
import {
|
|
adaptChatProjectionEvent,
|
|
createChatProjectionState,
|
|
projectChatEvents,
|
|
reduceChatProjection,
|
|
selectRenderableChatNodes,
|
|
} from '@/lib/projector/chat';
|
|
import type { CanonicalProjectEvent } from '@/lib/projector/types';
|
|
import { shouldProjectLegacyChatStep } from '@/store/chatEventProjectionBridge';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function event(
|
|
eventType: string,
|
|
payload: Record<string, unknown>,
|
|
sequence: number,
|
|
overrides: Partial<CanonicalProjectEvent> = {}
|
|
): CanonicalProjectEvent {
|
|
return {
|
|
eventId: `event-${sequence}`,
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
runSequence: sequence,
|
|
runVersion: sequence,
|
|
cloudCursor: sequence,
|
|
eventType,
|
|
payload,
|
|
legacyStep: null,
|
|
createdAt: `2026-08-18T00:00:${String(sequence).padStart(2, '0')}Z`,
|
|
source: 'canonical',
|
|
raw: payload,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('chat projection presentation contract', () => {
|
|
it('uses the explicit approval question for the durable timeline receipt', () => {
|
|
const state = projectChatEvents('project-1', [
|
|
event(
|
|
'approval.requested',
|
|
{
|
|
approval_id: 'approval:call-1',
|
|
prompt: {
|
|
title: 'Allow brave_search.web_search?',
|
|
question:
|
|
'The agent wants to run brave_search.web_search (mcp.tool.write).',
|
|
},
|
|
},
|
|
1
|
|
),
|
|
]);
|
|
|
|
expect(selectRenderableChatNodes(state)).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'interaction',
|
|
interactionId: 'approval:call-1',
|
|
interactionType: 'approval',
|
|
status: 'requested',
|
|
prompt:
|
|
'The agent wants to run brave_search.web_search (mcp.tool.write).',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('classifies known receipts without rendering false unsupported cards', () => {
|
|
const knownReceipts = [
|
|
'run.environment_resolved',
|
|
'run.timeout_policy_configured',
|
|
'run.attempt_environment_bound',
|
|
'run.forked',
|
|
'approval.expiry_observed',
|
|
'permission.action.allow',
|
|
'admission.accepted',
|
|
'execution.completed',
|
|
'model.invocation.dispatched',
|
|
'artifact.manifest.finalized',
|
|
'artifact.uploaded',
|
|
];
|
|
|
|
knownReceipts.forEach((eventType, index) => {
|
|
expect(adaptChatProjectionEvent(event(eventType, {}, index + 1))).toEqual(
|
|
{ kind: 'receipt', receiptType: eventType }
|
|
);
|
|
});
|
|
|
|
expect(
|
|
adaptChatProjectionEvent(event('future.super_event', {}, 20))
|
|
).toMatchObject({
|
|
kind: 'unsupported',
|
|
node: { kind: 'unknown', eventType: 'future.super_event' },
|
|
});
|
|
});
|
|
|
|
it('shows shared-workspace waits without cluttering uncontended tasks', () => {
|
|
expect(
|
|
adaptChatProjectionEvent(
|
|
event(
|
|
'workspace.writer.queued',
|
|
{ queue_position: 2, blocker_task_id: 'task-private' },
|
|
1
|
|
)
|
|
)
|
|
).toMatchObject({
|
|
kind: 'display',
|
|
node: {
|
|
kind: 'notice',
|
|
title: 'Waiting for workspace',
|
|
content: expect.stringContaining('Queue position: 2.'),
|
|
},
|
|
});
|
|
expect(
|
|
JSON.stringify(
|
|
adaptChatProjectionEvent(
|
|
event(
|
|
'workspace.writer.queued',
|
|
{ queue_position: 2, blocker_task_id: 'task-private' },
|
|
1
|
|
)
|
|
)
|
|
)
|
|
).not.toContain('task-private');
|
|
|
|
expect(
|
|
adaptChatProjectionEvent(
|
|
event('workspace.writer.acquired', { waited: false }, 2)
|
|
)
|
|
).toEqual({
|
|
kind: 'receipt',
|
|
receiptType: 'workspace.writer.acquired',
|
|
});
|
|
expect(
|
|
adaptChatProjectionEvent(
|
|
event('workspace.writer.acquired', { waited: true }, 3)
|
|
)
|
|
).toMatchObject({
|
|
kind: 'display',
|
|
node: {
|
|
kind: 'notice',
|
|
title: 'Workspace available',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('keeps canonical transcript, tool outcomes, and safe artifact identity semantic', () => {
|
|
const inputs = [
|
|
event('user.message', { content: 'Create the report' }, 1),
|
|
event('run.environment_resolved', { local_path: '/Users/alice' }, 2),
|
|
event('tool.failed', { tool_name: 'writer' }, 3),
|
|
event('tool.timed_out', { tool_name: 'browser' }, 4),
|
|
event('tool.outcome_unknown', { tool_name: 'email_sender' }, 5),
|
|
event(
|
|
'artifact.created',
|
|
{
|
|
artifact_id: 'artifact-1',
|
|
path: '/Users/alice/private/report.md',
|
|
relativePath: 'reports/report.md',
|
|
name: 'C:\\Users\\alice\\private\\report.md',
|
|
},
|
|
6
|
|
),
|
|
event(
|
|
'artifact.manifest.finalized',
|
|
{
|
|
artifacts: [
|
|
{ artifact_id: 'artifact-1', relativePath: 'reports/report.md' },
|
|
],
|
|
},
|
|
7
|
|
),
|
|
event(
|
|
'artifact.uploaded',
|
|
{
|
|
artifact_id: 'artifact-1',
|
|
relativePath: 'reports/report.md',
|
|
asset_ref: { key: 'safe/object-key' },
|
|
},
|
|
8
|
|
),
|
|
];
|
|
|
|
const state = projectChatEvents('project-1', inputs);
|
|
const nodes = selectRenderableChatNodes(state);
|
|
|
|
expect(nodes).toHaveLength(5);
|
|
expect(nodes[0]).toMatchObject({
|
|
kind: 'message',
|
|
role: 'user',
|
|
content: 'Create the report',
|
|
});
|
|
expect(
|
|
nodes
|
|
.filter((node) => node.kind === 'activity')
|
|
.map((node) => node.status)
|
|
).toEqual(['failed', 'timed_out', 'outcome_unknown']);
|
|
expect(nodes.at(-1)).toMatchObject({
|
|
kind: 'artifact',
|
|
artifactId: 'artifact-1',
|
|
path: 'reports/report.md',
|
|
name: 'report.md',
|
|
});
|
|
expect(JSON.stringify(state)).not.toContain('/Users/alice');
|
|
expect(JSON.stringify(state)).not.toContain('C:\\\\Users');
|
|
expect(state.seenEventIds).toEqual(
|
|
Object.fromEntries(inputs.map((input) => [input.eventId, true]))
|
|
);
|
|
});
|
|
|
|
it('never retains absolute or parent-traversing artifact paths', () => {
|
|
const state = projectChatEvents('project-1', [
|
|
event(
|
|
'artifact.created',
|
|
{
|
|
artifact_id: 'artifact-absolute',
|
|
relative_path: '../../Users/alice/secret.txt',
|
|
path: '/Users/alice/secret.txt',
|
|
name: '/Users/alice/secret.txt',
|
|
},
|
|
1
|
|
),
|
|
]);
|
|
|
|
expect(selectRenderableChatNodes(state)).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'artifact',
|
|
artifactId: 'artifact-absolute',
|
|
path: 'secret.txt',
|
|
name: 'secret.txt',
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(state)).not.toContain('/Users/alice');
|
|
expect(JSON.stringify(state)).not.toContain('../');
|
|
});
|
|
|
|
it('hides an empty assistant final instead of rendering an empty bubble', () => {
|
|
expect(adaptChatProjectionEvent(event('assistant.final', {}, 1))).toEqual({
|
|
kind: 'hidden',
|
|
reason: 'assistant.final.empty',
|
|
});
|
|
|
|
const state = projectChatEvents('project-1', [
|
|
event('assistant.final', {}, 1),
|
|
event('run.completed', {}, 2),
|
|
]);
|
|
expect(selectRenderableChatNodes(state)).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'run_status',
|
|
status: 'completed',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('prefers canonical transcript events over legacy history fallbacks per Run', () => {
|
|
const mixed = projectChatEvents('project-1', [
|
|
event('legacy.confirmed', { content: 'Legacy prompt' }, 1, {
|
|
legacyStep: 'confirmed',
|
|
}),
|
|
event('user.message', { content: 'Canonical prompt' }, 2),
|
|
event('legacy.end', { content: 'Legacy answer' }, 3, {
|
|
legacyStep: 'end',
|
|
}),
|
|
event('assistant.final', { content: 'Canonical answer' }, 4, {
|
|
legacyStep: 'end',
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
presentChatSemanticEntities(selectRenderableChatNodes(mixed)).map(
|
|
(node) => (node.kind === 'message' ? node.content : node.eventType)
|
|
)
|
|
).toEqual(['Canonical prompt', 'Canonical answer']);
|
|
|
|
const legacyOnly = projectChatEvents('project-1', [
|
|
event('legacy.confirmed', { content: 'Old prompt' }, 1, {
|
|
legacyStep: 'confirmed',
|
|
}),
|
|
event('legacy.end', { content: 'Old answer' }, 2, {
|
|
legacyStep: 'end',
|
|
}),
|
|
]);
|
|
expect(
|
|
presentChatSemanticEntities(selectRenderableChatNodes(legacyOnly)).map(
|
|
(node) => (node.kind === 'message' ? node.content : node.eventType)
|
|
)
|
|
).toEqual(['Old prompt', 'Old answer']);
|
|
});
|
|
|
|
it('coalesces typed message lifecycle receipts by message_id', () => {
|
|
const state = projectChatEvents('project-1', [
|
|
event(
|
|
'message.created',
|
|
{ message_id: 'message-1', role: 'assistant', content: 'Hel' },
|
|
1
|
|
),
|
|
event('message.delta', { message_id: 'message-1', delta: 'lo ' }, 2),
|
|
event('message.delta', { message_id: 'message-1', delta: 'world' }, 3),
|
|
event(
|
|
'message.completed',
|
|
{ message_id: 'message-1', role: 'assistant' },
|
|
4
|
|
),
|
|
]);
|
|
|
|
const presented = presentChatSemanticEntities(
|
|
selectRenderableChatNodes(state)
|
|
);
|
|
expect(presented).toHaveLength(1);
|
|
expect(presented[0]).toMatchObject({
|
|
kind: 'message',
|
|
messageId: 'message-1',
|
|
content: 'Hello world',
|
|
status: 'complete',
|
|
});
|
|
expect(presentChatSemanticEntities(presented)).toEqual(presented);
|
|
});
|
|
|
|
it('keeps only the latest Run lifecycle status for presentation', () => {
|
|
const state = projectChatEvents('project-1', [
|
|
event('run.attempt_created', {}, 1),
|
|
event('user.message', { content: 'Research this' }, 2),
|
|
event('run.attempt_started', {}, 3),
|
|
event('run.completed', {}, 4),
|
|
]);
|
|
|
|
const presented = presentChatSemanticEntities(
|
|
selectRenderableChatNodes(state)
|
|
);
|
|
const statuses = presented.filter((node) => node.kind === 'run_status');
|
|
|
|
expect(statuses).toHaveLength(1);
|
|
expect(statuses[0]).toMatchObject({
|
|
eventId: 'event-4',
|
|
status: 'completed',
|
|
});
|
|
expect(presentChatSemanticEntities(presented)).toEqual(presented);
|
|
});
|
|
|
|
it('folds correlated agent activation and completion receipts', () => {
|
|
const state = projectChatEvents('project-1', [
|
|
event(
|
|
'legacy.activate_agent',
|
|
{
|
|
agent_name: 'single_agent',
|
|
agent_id: 'agent-1',
|
|
process_task_id: 'task-1',
|
|
message: '=== Lightweight Memory ===',
|
|
},
|
|
1,
|
|
{ legacyStep: 'activate_agent' }
|
|
),
|
|
event(
|
|
'legacy.deactivate_agent',
|
|
{
|
|
agent_name: 'single_agent',
|
|
agent_id: 'agent-1',
|
|
process_task_id: 'task-1',
|
|
message: 'Research complete',
|
|
},
|
|
2,
|
|
{ legacyStep: 'deactivate_agent' }
|
|
),
|
|
event(
|
|
'legacy.activate_agent',
|
|
{
|
|
agent_name: 'writer_agent',
|
|
agent_id: 'agent-2',
|
|
process_task_id: 'task-2',
|
|
message: 'Draft the report',
|
|
},
|
|
3,
|
|
{ legacyStep: 'activate_agent' }
|
|
),
|
|
]);
|
|
|
|
const presented = presentChatSemanticEntities(
|
|
selectRenderableChatNodes(state)
|
|
);
|
|
const agents = presented.filter(
|
|
(node) => node.kind === 'activity' && node.activityType === 'agent'
|
|
);
|
|
|
|
expect(agents).toHaveLength(2);
|
|
expect(agents[0]).toMatchObject({
|
|
eventId: 'event-1',
|
|
status: 'completed',
|
|
phase: 'completed',
|
|
title: 'single_agent',
|
|
});
|
|
expect(agents[1]).toMatchObject({
|
|
eventId: 'event-3',
|
|
status: 'running',
|
|
phase: 'unknown',
|
|
});
|
|
expect(presentChatSemanticEntities(presented)).toEqual(presented);
|
|
});
|
|
|
|
it('gives migrated live transcript families to the canonical lane only', () => {
|
|
for (const step of ['confirmed', 'end', 'decompose_text', 'write_file']) {
|
|
expect(shouldProjectLegacyChatStep(step, true)).toBe(false);
|
|
expect(shouldProjectLegacyChatStep(step, false)).toBe(true);
|
|
}
|
|
expect(shouldProjectLegacyChatStep('ask', true)).toBe(true);
|
|
});
|
|
|
|
it('converges batch hydration, live reduction, and duplicate delivery', () => {
|
|
const inputs = [
|
|
event('user.message', { content: 'Summarize the report' }, 1),
|
|
event('run.environment_resolved', {}, 2),
|
|
event('tool.completed', { tool_name: 'reader' }, 3),
|
|
event('assistant.final', { content: 'Summary complete' }, 4, {
|
|
legacyStep: 'end',
|
|
}),
|
|
event('run.completed', {}, 5),
|
|
];
|
|
const hydrated = projectChatEvents('project-1', inputs);
|
|
const live = inputs.reduce(
|
|
(state, input) => reduceChatProjection(state, input),
|
|
createChatProjectionState('project-1')
|
|
);
|
|
const duplicate = reduceChatProjection(live, inputs[2]);
|
|
|
|
const visible = (state: typeof hydrated) =>
|
|
presentChatSemanticEntities(selectRenderableChatNodes(state));
|
|
expect(visible(live)).toEqual(visible(hydrated));
|
|
expect(duplicate).toBe(live);
|
|
});
|
|
});
|