mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 09:02:23 +00:00
497 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|