eigent/test/unit/store/chatStore.durableReplay.test.ts
Douglas Lai 05d4f7ef3d
Feat/refactor chatbox event bus (#1852)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-18 23:40:29 +08:00

497 lines
14 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 {
acceptCanonicalRunEvent,
admitDurableRunResume,
buildUploadRequestId,
canonicalRunEventToLegacyMessage,
collectTaskUploadFiles,
createCanonicalRunEventCursor,
createChatStoreInstance,
extractAgentMessageContent,
hasProjectedHumanInteraction,
mergeFileInfoLists,
normalizeTaskArtifactFileList,
removeResolvedInteractionMessages,
shouldAppendTaskForConfirmedEvent,
} from '@/store/chatStore';
import { describe, expect, it, vi } from 'vitest';
describe('canonical Run replay projection', () => {
it('keeps structured interaction payloads out of Message.content', () => {
expect(
extractAgentMessageContent({
interaction_id: 'approval-1',
interaction_type: 'approval',
title: 'Allow write?',
})
).toBe('');
expect(
extractAgentMessageContent({
interaction_id: 'question-1',
question: 'Which account?',
})
).toBe('Which account?');
expect(extractAgentMessageContent('legacy text')).toBe('legacy text');
});
it('surfaces Resume admission failures before execution starts', async () => {
const error = Object.assign(new Error('Unsafe tool outcome'), {
status: 409,
});
const post = vi.fn().mockRejectedValue(error);
await expect(
admitDurableRunResume('run/unsafe', 'resume-request-1', post)
).rejects.toBe(error);
expect(post).toHaveBeenCalledWith('/runs/run%2Funsafe/resume', {
request_id: 'resume-request-1',
reason: 'explicit_resume',
});
});
it('unwraps legacy UI events and projects durable interaction decisions', () => {
expect(
canonicalRunEventToLegacyMessage({
event_type: 'legacy.end',
legacy_step: 'end',
payload: { message: 'finished' },
created_at: 1_786_026_414.75,
})
).toEqual({
step: 'end',
data: { message: 'finished' },
timestamp: 1_786_026_414.75,
});
expect(
canonicalRunEventToLegacyMessage({
event_type: 'approval.decided',
legacy_step: null,
payload: {
interaction_id: 'approval-1',
decision: 'approved',
},
created_at: 1_786_026_415,
})
).toEqual({
step: 'human_reply',
data: {
interaction_id: 'approval-1',
decision: 'approved',
__durable_interaction_resolution: true,
},
timestamp: 1_786_026_415,
});
expect(
canonicalRunEventToLegacyMessage({
event_type: 'interaction.resolved',
legacy_step: null,
payload: {
interaction_id: 'question-2',
decision: { reply: 'done' },
},
created_at: 1_786_026_416,
})
).toEqual({
step: 'human_reply',
data: {
interaction_id: 'question-2',
decision: { reply: 'done' },
__durable_interaction_resolution: true,
},
timestamp: 1_786_026_416,
});
expect(
canonicalRunEventToLegacyMessage({
event_type: 'artifact.manifest.finalized',
legacy_step: null,
payload: {
scan_status: 'complete',
artifacts: [
{
path: '/workspace/report.csv',
relativePath: 'report.csv',
filename: 'report.csv',
},
],
},
created_at: 1_786_026_414.9,
})
).toEqual({
step: 'artifact_manifest',
data: {
scan_status: 'complete',
artifacts: [
{
path: '/workspace/report.csv',
relativePath: 'report.csv',
filename: 'report.csv',
},
],
},
timestamp: 1_786_026_414.9,
});
expect(
canonicalRunEventToLegacyMessage({
event_type: 'run.failed',
legacy_step: null,
payload: {
error_type: 'InvalidRunTransitionError',
message: 'An unresolved Tool outcome prevents completion.',
},
created_at: 1_786_026_417,
})
).toEqual({
step: 'error',
data: {
error_type: 'InvalidRunTransitionError',
message: 'An unresolved Tool outcome prevents completion.',
},
timestamp: 1_786_026_417,
});
expect(
canonicalRunEventToLegacyMessage({
event_type: 'tool.completed',
legacy_step: null,
payload: { outcome: 'completed' },
})
).toBeNull();
expect(
canonicalRunEventToLegacyMessage({
run_id: 'run-1',
after_sequence: 3,
})
).toBeNull();
});
it('keeps Resume attempts of one local durable Run in one task', () => {
expect(
shouldAppendTaskForConfirmedEvent({
projectId: 'project-1',
question: 'Resume from persisted context',
messageContent: 'Original prompt',
skipFirstConfirm: false,
replaySource: 'local_durable',
})
).toBe(false);
// A later confirmed frame on the legacy cloud Project stream still marks
// a genuinely new Run and retains the migration behaviour.
expect(
shouldAppendTaskForConfirmedEvent({
projectId: 'project-1',
question: 'A new follow-up',
messageContent: 'Original prompt',
skipFirstConfirm: false,
replaySource: 'cloud',
})
).toBe(true);
});
it('removes only the message for the resolved interaction', () => {
const messages = [
{
id: 'approval-card',
role: 'agent' as const,
content: 'Approve?',
interaction: {
interaction_id: 'approval-1',
interaction_type: 'approval' as const,
run_id: 'run-1',
version: 0,
},
},
{
id: 'other-card',
role: 'agent' as const,
content: 'Other question',
interaction: {
interaction_id: 'question-2',
interaction_type: 'question' as const,
run_id: 'run-1',
version: 0,
},
},
];
expect(removeResolvedInteractionMessages(messages, 'approval-1')).toEqual([
messages[1],
]);
});
it('keeps resolved interaction ids monotonic and removes replayed cards', () => {
const store = createChatStoreInstance();
store.getState().create('run-1');
const approval = {
id: 'approval-card',
role: 'agent' as const,
content: 'Approve?',
step: 'ask',
interaction: {
interaction_id: 'approval-1',
interaction_type: 'approval' as const,
run_id: 'run-1',
version: 0,
},
};
store.getState().addMessages('run-1', approval);
store.getState().markHumanInteractionResolved('run-1', 'approval-1');
store.getState().markHumanInteractionResolved('run-1', 'approval-1');
expect(store.getState().tasks['run-1'].messages).toEqual([]);
expect(store.getState().tasks['run-1'].resolvedInteractionIds).toEqual([
'approval-1',
]);
expect(
hasProjectedHumanInteraction(
store.getState().tasks['run-1'],
'approval-1'
)
).toBe(true);
});
it('deduplicates a pending interaction across message and queued ASK lanes', () => {
const interaction = {
interaction_id: 'approval-1',
interaction_type: 'approval' as const,
run_id: 'run-1',
version: 0,
};
const message = {
id: 'approval-card',
role: 'agent' as const,
content: 'Approve?',
interaction,
};
expect(
hasProjectedHumanInteraction(
{ messages: [message], askList: [], resolvedInteractionIds: [] },
interaction.interaction_id
)
).toBe(true);
expect(
hasProjectedHumanInteraction(
{ messages: [], askList: [message], resolvedInteractionIds: [] },
interaction.interaction_id
)
).toBe(true);
expect(
hasProjectedHumanInteraction(
{ messages: [], askList: [], resolvedInteractionIds: [] },
interaction.interaction_id
)
).toBe(false);
});
it('deduplicates reconnect replay by sequence and event_id', () => {
const cursor = createCanonicalRunEventCursor();
const first = { sequence: 1, event_id: 'event-1' };
expect(acceptCanonicalRunEvent(cursor, first, '1')).toBe(true);
expect(acceptCanonicalRunEvent(cursor, first, '1')).toBe(false);
expect(
acceptCanonicalRunEvent(cursor, {
sequence: 2,
event_id: 'event-1',
})
).toBe(false);
expect(
acceptCanonicalRunEvent(cursor, {
sequence: 2,
event_id: 'event-2',
})
).toBe(true);
expect(cursor.lastSequence).toBe(2);
});
it('keeps same-named files when their workspace-relative paths differ', () => {
const artifacts = Array.from({ length: 21 }, (_, index) => ({
filename: 'index.html',
path: `/workspace/chapter-2/lesson-${index + 1}/index.html`,
relativePath: `chapter-2/lesson-${index + 1}/index.html`,
changeType: 'generated',
}));
const files = normalizeTaskArtifactFileList(artifacts);
expect(files).toHaveLength(21);
expect(files[0]).toEqual(
expect.objectContaining({
relativePath: 'chapter-2/lesson-1/index.html',
artifactChange: 'generated',
})
);
expect(files[20]).toEqual(
expect.objectContaining({
relativePath: 'chapter-2/lesson-21/index.html',
artifactChange: 'generated',
})
);
expect(mergeFileInfoLists([], files)).toHaveLength(21);
expect(
mergeFileInfoLists(
[
{
name: 'index.html',
type: 'html',
path: '/workspace/chapter-2/lesson-1/index.html',
},
],
[files[0]]
)
).toHaveLength(1);
});
it('keeps Cloud-safe Artifact metadata when the local path is redacted', () => {
const files = normalizeTaskArtifactFileList([
{
artifact_id: 'art-cloud-1',
filename: 'report.csv',
relativePath: 'reports/report.csv',
changeType: 'generated',
uploadPolicy: 'agent_generated',
localPathAvailable: false,
},
]);
expect(files).toEqual([
expect.objectContaining({
artifactId: 'art-cloud-1',
name: 'report.csv',
path: '',
relativePath: 'reports/report.csv',
localPathAvailable: false,
}),
]);
});
it('keeps canonical Artifact uploads out of the Renderer lane', () => {
const candidates = collectTaskUploadFiles(
[],
[],
[],
[
{
artifactId: 'art-generated',
name: 'generated.csv',
type: 'csv',
path: '/workspace/generated.csv',
uploadPolicy: 'agent_generated',
},
{
artifactId: 'art-local',
name: 'private.csv',
type: 'csv',
path: '/workspace/private.csv',
uploadPolicy: 'metadata_only',
},
]
);
expect(candidates).toEqual([]);
});
it('separates a CAMEL log basename from its logical path metadata', () => {
const candidates = collectTaskUploadFiles(
[
{
path: '/Users/test/.eigent/camel_logs/agent-1/conv.json',
name: 'conv.json',
relativePath: 'agent-1',
source: 'camel_log',
},
],
[],
[]
);
expect(candidates).toEqual([
expect.objectContaining({
uploadName: 'conv.json',
source: 'camel_log',
logicalPath: 'agent-1/conv.json',
}),
]);
expect(candidates[0].uploadName).not.toContain('/');
});
it('derives a bounded idempotency key without exposing the local path', async () => {
const file = {
path: '/Users/alice/private/camel_logs/agent-1/conv.json',
source: 'camel_log' as const,
logicalPath: 'agent-1/conv.json',
};
const first = await buildUploadRequestId('project-1', file);
const replay = await buildUploadRequestId('project-1', file);
expect(first).toBe(replay);
expect(first).toHaveLength(83);
expect(first).not.toContain('alice');
expect(first).not.toContain('conv.json');
});
it('matches URL-encoded stream paths and legacy x-prefixed paths', () => {
expect(
mergeFileInfoLists(
[
{
name: 'report.csv',
type: 'csv',
path: '/files/stream?path=reports%2Freport.csv&project_id=1',
isRemote: true,
},
],
[
{
name: 'report.csv',
type: 'csv',
path: '/workspace/reports/report.csv',
relativePath: 'reports/report.csv',
artifactChange: 'changed',
},
]
)
).toMatchObject([
{
path: '/files/stream?path=reports%2Freport.csv&project_id=1',
artifactChange: 'changed',
},
]);
expect(
mergeFileInfoLists(
[
{
name: 'report.csv',
type: 'csv',
path: 'x:/Users/test/report.csv',
},
],
[
{
name: 'report.csv',
type: 'csv',
path: '/Users/test/report.csv',
},
]
)
).toHaveLength(1);
});
});