mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-27 17:41:56 +00:00
951 lines
26 KiB
TypeScript
951 lines
26 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 {
|
|
completeProjectViewResync,
|
|
createProjectViewState,
|
|
deriveLiveEffects,
|
|
importIndexedDbV1,
|
|
importLegacyChatSteps,
|
|
importLocalMemoryV1,
|
|
normalizeEvent,
|
|
projectRawEvents,
|
|
projectSnapshot,
|
|
reduceProjectView,
|
|
selectPendingLegacyAsk,
|
|
selectRunArtifacts,
|
|
} from '@/lib/projector';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function event(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
event_id: 'event-1',
|
|
project_id: 'project-1',
|
|
run_id: 'run-1',
|
|
run_sequence: 1,
|
|
run_version: 1,
|
|
cloud_cursor: 1,
|
|
event_type: 'step.created',
|
|
payload: { content: 'hello' },
|
|
legacy_step: 'activate_agent',
|
|
created_at: '2026-08-05T10:00:00Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('projector pipeline', () => {
|
|
it('keeps established Run origin immutable and fails closed on conflicts', () => {
|
|
const cloud = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
normalizeEvent(event({ origin: 'cloud_restore' }))
|
|
);
|
|
const conflicted = reduceProjectView(
|
|
cloud,
|
|
normalizeEvent(
|
|
event({
|
|
event_id: 'event-2',
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
cloud_cursor: 2,
|
|
origin: 'local',
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(conflicted.runs['run-1']?.origin).toBe('cloud_restore');
|
|
expect(conflicted.needsResync).toBe(true);
|
|
expect(conflicted.resyncReason).toBe(
|
|
'run_origin_conflict:run-1:cloud_restore:local'
|
|
);
|
|
});
|
|
|
|
it('allows one authoritative origin to fill an initially unknown Run', () => {
|
|
const unknown = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
normalizeEvent(event())
|
|
);
|
|
const local = reduceProjectView(
|
|
unknown,
|
|
normalizeEvent(
|
|
event({
|
|
event_id: 'event-2',
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
cloud_cursor: 2,
|
|
origin: 'local',
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(unknown.runs['run-1']?.origin).toBeNull();
|
|
expect(local.runs['run-1']?.origin).toBe('local');
|
|
expect(local.needsResync).toBe(false);
|
|
});
|
|
|
|
it('lets a snapshot aggregate correct newer buffered provenance', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
event({
|
|
event_id: 'local-2',
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
cloud_cursor: 2,
|
|
origin: 'local',
|
|
}),
|
|
],
|
|
'rehydrate'
|
|
).state;
|
|
const snapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 1,
|
|
runs: [
|
|
{
|
|
run_id: 'run-1',
|
|
status: 'running',
|
|
expected_next_run_sequence: 2,
|
|
run_version: 1,
|
|
updated_at: '2026-08-05T10:00:00Z',
|
|
origin: 'cloud_restore',
|
|
},
|
|
],
|
|
recent_events: [],
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(snapshot.runs['run-1']).toMatchObject({
|
|
lastSequence: 2,
|
|
origin: 'cloud_restore',
|
|
});
|
|
});
|
|
|
|
it('treats an explicit unknown snapshot origin as fail-closed authority', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
event({
|
|
event_id: 'local-2',
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
cloud_cursor: 2,
|
|
origin: 'local',
|
|
}),
|
|
],
|
|
'rehydrate'
|
|
).state;
|
|
const snapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 1,
|
|
runs: [
|
|
{
|
|
run_id: 'run-1',
|
|
status: 'running',
|
|
expected_next_run_sequence: 2,
|
|
run_version: 1,
|
|
updated_at: '2026-08-05T10:00:00Z',
|
|
origin: null,
|
|
},
|
|
],
|
|
recent_events: [],
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(snapshot.runs['run-1']?.origin).toBeNull();
|
|
});
|
|
|
|
it('preserves established provenance when an older snapshot omits origin', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
event({
|
|
event_id: 'local-2',
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
cloud_cursor: 2,
|
|
origin: 'local',
|
|
}),
|
|
],
|
|
'rehydrate'
|
|
).state;
|
|
const snapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 1,
|
|
runs: [
|
|
{
|
|
run_id: 'run-1',
|
|
status: 'running',
|
|
expected_next_run_sequence: 2,
|
|
run_version: 1,
|
|
updated_at: '2026-08-05T10:00:00Z',
|
|
},
|
|
],
|
|
recent_events: [],
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(snapshot.runs['run-1']?.origin).toBe('local');
|
|
});
|
|
|
|
it('deduplicates by event ID without changing object identity', () => {
|
|
const normalized = normalizeEvent(event());
|
|
const initial = createProjectViewState('project-1', 'live');
|
|
const first = reduceProjectView(initial, normalized);
|
|
const duplicate = reduceProjectView(first, normalized);
|
|
|
|
expect(duplicate).toBe(first);
|
|
expect(first.legacySteps).toHaveLength(1);
|
|
expect(first.currentCursor).toBe(1);
|
|
});
|
|
|
|
it('restores durable Artifacts outside the bounded snapshot event tail', () => {
|
|
const snapshot = projectSnapshot({
|
|
project_id: 'project-1',
|
|
current_cursor: 100,
|
|
recent_events: [
|
|
event({
|
|
event_id: 'recent-100',
|
|
cloud_cursor: 100,
|
|
run_sequence: 100,
|
|
}),
|
|
],
|
|
artifact_events: [
|
|
event({
|
|
event_id: 'manifest-1',
|
|
cloud_cursor: 1,
|
|
event_type: 'artifact.manifest.finalized',
|
|
legacy_step: null,
|
|
payload: {
|
|
artifacts: [
|
|
{
|
|
artifact_id: 'artifact-1',
|
|
filename: 'report.csv',
|
|
relativePath: 'reports/report.csv',
|
|
changeType: 'generated',
|
|
uploadPolicy: 'agent_generated',
|
|
localPathAvailable: false,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
event({
|
|
event_id: 'upload-1',
|
|
cloud_cursor: 2,
|
|
run_sequence: 2,
|
|
event_type: 'artifact.uploaded',
|
|
legacy_step: null,
|
|
payload: {
|
|
artifact_id: 'artifact-1',
|
|
asset_ref: {
|
|
chat_file_id: 7,
|
|
bucket: 'assets',
|
|
key: 'reports/report.csv',
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
events_truncated: true,
|
|
});
|
|
|
|
expect(snapshot.needsResync).toBe(false);
|
|
expect(selectRunArtifacts(snapshot, 'run-1')).toEqual([
|
|
expect.objectContaining({
|
|
artifactId: 'artifact-1',
|
|
assetRef: expect.objectContaining({
|
|
chatFileId: 7,
|
|
key: 'reports/report.csv',
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('detects both Project cursor and Run sequence gaps', () => {
|
|
const first = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
normalizeEvent(event())
|
|
);
|
|
const next = reduceProjectView(
|
|
first,
|
|
normalizeEvent(
|
|
event({
|
|
event_id: 'event-3',
|
|
cloud_cursor: 3,
|
|
run_sequence: 3,
|
|
run_version: 3,
|
|
})
|
|
)
|
|
);
|
|
expect(next.needsResync).toBe(true);
|
|
expect(next.resyncReason).toContain('run_sequence_gap');
|
|
expect(next.currentCursor).toBe(1);
|
|
expect(next.resyncTargetCursor).toBe(3);
|
|
expect(next.seenEventIds['event-3']).toBeUndefined();
|
|
expect(next.legacySteps).toHaveLength(1);
|
|
});
|
|
|
|
it('replays a cursor gap from the last contiguous cursor', () => {
|
|
const initial = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
normalizeEvent(event())
|
|
);
|
|
const gap = reduceProjectView(
|
|
initial,
|
|
normalizeEvent(
|
|
event({ event_id: 'event-3', cloud_cursor: 3, run_sequence: 3 })
|
|
)
|
|
);
|
|
const filled = reduceProjectView(
|
|
gap,
|
|
normalizeEvent(
|
|
event({ event_id: 'event-2', cloud_cursor: 2, run_sequence: 2 })
|
|
)
|
|
);
|
|
const replayed = reduceProjectView(
|
|
filled,
|
|
normalizeEvent(
|
|
event({ event_id: 'event-3', cloud_cursor: 3, run_sequence: 3 })
|
|
)
|
|
);
|
|
|
|
expect(filled.currentCursor).toBe(2);
|
|
expect(replayed.currentCursor).toBe(3);
|
|
expect(replayed.seenEventIds['event-3']).toBe(true);
|
|
const recovered = completeProjectViewResync(replayed, 3);
|
|
expect(recovered.needsResync).toBe(false);
|
|
expect(recovered.resyncReason).toBeNull();
|
|
expect(recovered.resyncTargetCursor).toBeNull();
|
|
});
|
|
|
|
it('keeps resync active until the authoritative cursor is contiguous', () => {
|
|
const state = {
|
|
...createProjectViewState('project-1', 'live'),
|
|
currentCursor: 3,
|
|
needsResync: true,
|
|
resyncReason: 'cloud_cursor_gap:4:5',
|
|
resyncTargetCursor: 5,
|
|
};
|
|
|
|
expect(completeProjectViewResync(state, 5)).toBe(state);
|
|
});
|
|
|
|
it('does not clear a concurrently observed live gap after an older delta page', () => {
|
|
const state = {
|
|
...createProjectViewState('project-1', 'live'),
|
|
currentCursor: 10,
|
|
needsResync: true,
|
|
resyncReason: 'cloud_cursor_gap:11:12',
|
|
resyncTargetCursor: 12,
|
|
};
|
|
|
|
expect(completeProjectViewResync(state, 10)).toBe(state);
|
|
});
|
|
|
|
it('does not clear a non-gap resync at a matching cursor', () => {
|
|
const state = {
|
|
...createProjectViewState('project-1', 'live'),
|
|
currentCursor: 3,
|
|
needsResync: true,
|
|
resyncReason: 'project_scope_mismatch:project-2',
|
|
resyncTargetCursor: null,
|
|
};
|
|
|
|
expect(completeProjectViewResync(state, 3)).toBe(state);
|
|
});
|
|
|
|
it('detects a missing prefix when the first live event does not start at one', () => {
|
|
const result = projectRawEvents(
|
|
'project-1',
|
|
[event({ cloud_cursor: 4, run_sequence: 3 })],
|
|
'live'
|
|
);
|
|
expect(result.state.needsResync).toBe(true);
|
|
expect(result.effects).toContainEqual({
|
|
type: 'request_resync',
|
|
reason: 'run_sequence_gap:run-1:1:3',
|
|
});
|
|
});
|
|
|
|
it('requests another resync when a later gap extends the missing range', () => {
|
|
const initial = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
normalizeEvent(event())
|
|
);
|
|
const firstGapEvent = normalizeEvent(
|
|
event({ event_id: 'event-3', cloud_cursor: 3, run_sequence: 3 })
|
|
);
|
|
const firstGap = reduceProjectView(initial, firstGapEvent);
|
|
const laterGapEvent = normalizeEvent(
|
|
event({ event_id: 'event-4', cloud_cursor: 4, run_sequence: 4 })
|
|
);
|
|
const laterGap = reduceProjectView(firstGap, laterGapEvent);
|
|
|
|
expect(
|
|
deriveLiveEffects(firstGap, laterGap, laterGapEvent, 'live')
|
|
).toEqual([
|
|
{
|
|
type: 'request_resync',
|
|
reason: 'run_sequence_gap:run-1:2:4',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('derives effects only in live mode', () => {
|
|
const normalized = normalizeEvent(
|
|
event({ event_type: 'run.completed', legacy_step: 'end' })
|
|
);
|
|
const initial = createProjectViewState('project-1', 'live');
|
|
const next = reduceProjectView(initial, normalized);
|
|
expect(deriveLiveEffects(initial, next, normalized, 'live')).toEqual([
|
|
{ type: 'scroll_to_latest', eventId: 'event-1' },
|
|
]);
|
|
expect(deriveLiveEffects(initial, next, normalized, 'rehydrate')).toEqual(
|
|
[]
|
|
);
|
|
expect(deriveLiveEffects(initial, next, normalized, 'playback')).toEqual(
|
|
[]
|
|
);
|
|
});
|
|
|
|
it('rehydrates a semantic snapshot atomically at its watermark', () => {
|
|
const snapshot = projectSnapshot({
|
|
project_id: 'project-1',
|
|
current_cursor: 12,
|
|
recent_events: [event()],
|
|
events_truncated: true,
|
|
});
|
|
expect(snapshot.currentCursor).toBe(12);
|
|
expect(snapshot.eventsTruncated).toBe(true);
|
|
expect(snapshot.mode).toBe('rehydrate');
|
|
expect(snapshot.needsResync).toBe(false);
|
|
expect(snapshot.runs['run-1'].lastSequence).toBe(1);
|
|
});
|
|
|
|
it('preserves known local history when a replacement snapshot is truncated', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
event(),
|
|
event({
|
|
event_id: 'event-2',
|
|
cloud_cursor: 2,
|
|
run_sequence: 2,
|
|
payload: { content: 'older local history', __legacy_step_id: 2 },
|
|
}),
|
|
],
|
|
'rehydrate'
|
|
).state;
|
|
const snapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 12,
|
|
recent_events: [
|
|
event({
|
|
event_id: 'event-12',
|
|
cloud_cursor: 12,
|
|
run_sequence: 12,
|
|
payload: { content: 'recent', __legacy_step_id: 12 },
|
|
}),
|
|
],
|
|
events_truncated: true,
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(snapshot.eventsTruncated).toBe(true);
|
|
expect(snapshot.legacySteps.map((step) => step.stepId)).toEqual([
|
|
'event-1',
|
|
2,
|
|
12,
|
|
]);
|
|
});
|
|
|
|
it('does not regress live history or cursor when a snapshot response is stale', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
event(),
|
|
event({
|
|
event_id: 'event-2',
|
|
cloud_cursor: 2,
|
|
run_sequence: 2,
|
|
created_at: '2026-08-05T10:00:02Z',
|
|
payload: { __legacy_step_id: 2 },
|
|
}),
|
|
],
|
|
'live'
|
|
).state;
|
|
const staleSnapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 1,
|
|
recent_events: [event()],
|
|
events_truncated: false,
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(staleSnapshot.currentCursor).toBe(2);
|
|
expect(staleSnapshot.legacySteps.map((step) => step.stepId)).toEqual([
|
|
'event-1',
|
|
2,
|
|
]);
|
|
expect(staleSnapshot.runs['run-1'].lastSequence).toBe(2);
|
|
});
|
|
|
|
it('does not let a stale snapshot clear a newer live gap', () => {
|
|
const gap = reduceProjectView(
|
|
reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
normalizeEvent(event({ cloud_cursor: 10, run_sequence: 1 }))
|
|
),
|
|
normalizeEvent(
|
|
event({ event_id: 'event-12', cloud_cursor: 12, run_sequence: 2 })
|
|
)
|
|
);
|
|
const staleSnapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 10,
|
|
recent_events: [],
|
|
events_truncated: true,
|
|
},
|
|
gap
|
|
);
|
|
|
|
expect(staleSnapshot.needsResync).toBe(true);
|
|
expect(staleSnapshot.resyncTargetCursor).toBe(12);
|
|
});
|
|
|
|
it('orders cross-lane steps by time instead of comparing incompatible sequences', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 100,
|
|
step: 'ask',
|
|
data: { content: 'Continue?' },
|
|
timestamp: 200,
|
|
},
|
|
]),
|
|
'live'
|
|
).state;
|
|
const snapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 2,
|
|
recent_events: [
|
|
event({
|
|
event_id: 'reply-2',
|
|
cloud_cursor: 2,
|
|
run_sequence: 2,
|
|
legacy_step: 'human_reply',
|
|
payload: { content: 'Yes' },
|
|
created_at: '2026-08-05T10:00:01Z',
|
|
}),
|
|
],
|
|
events_truncated: true,
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(snapshot.legacySteps.map((step) => step.step)).toEqual([
|
|
'ask',
|
|
'human_reply',
|
|
]);
|
|
expect(selectPendingLegacyAsk(snapshot, new Set())).toBeNull();
|
|
});
|
|
|
|
it('preserves legacy-only live history across a complete canonical snapshot', () => {
|
|
const previous = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 7,
|
|
step: 'ask',
|
|
data: { content: 'Continue?' },
|
|
},
|
|
])[0],
|
|
],
|
|
'live'
|
|
).state;
|
|
const snapshot = projectSnapshot(
|
|
{
|
|
project_id: 'project-1',
|
|
current_cursor: 1,
|
|
recent_events: [event()],
|
|
events_truncated: false,
|
|
},
|
|
previous
|
|
);
|
|
|
|
expect(snapshot.eventsTruncated).toBe(false);
|
|
expect(snapshot.legacySteps.map((step) => step.step)).toEqual([
|
|
'activate_agent',
|
|
'ask',
|
|
]);
|
|
expect(snapshot.legacySteps.at(-1)?.step).toBe('ask');
|
|
});
|
|
|
|
it('deduplicates canonical and legacy frames by stable step identity', () => {
|
|
const canonical = normalizeEvent(
|
|
event({ payload: { __legacy_step_id: 9, __legacy_data: { value: 1 } } })
|
|
);
|
|
const legacy = importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'activate_agent',
|
|
data: { value: 1 },
|
|
},
|
|
])[0];
|
|
const initial = createProjectViewState('project-1', 'live');
|
|
const first = reduceProjectView(initial, legacy);
|
|
const second = reduceProjectView(first, canonical);
|
|
|
|
expect(second.legacySteps).toHaveLength(1);
|
|
expect(second.seenEventIds[legacy.eventId]).toBe(true);
|
|
expect(second.seenEventIds[canonical.eventId]).toBe(true);
|
|
});
|
|
|
|
it('deduplicates the production canonical shape without a legacy step id', () => {
|
|
const createdAt = '2026-08-05T10:00:00Z';
|
|
const legacy = importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'activate_agent',
|
|
data: { value: 1 },
|
|
timestamp: Date.parse(createdAt) / 1000 + 0.01,
|
|
},
|
|
])[0];
|
|
const canonical = normalizeEvent(
|
|
event({
|
|
event_id: 'canonical-production-event',
|
|
payload: { value: 1 },
|
|
created_at: createdAt,
|
|
})
|
|
);
|
|
const initial = createProjectViewState('project-1', 'live');
|
|
const projected = reduceProjectView(
|
|
reduceProjectView(initial, legacy),
|
|
canonical
|
|
);
|
|
|
|
expect(projected.legacySteps).toHaveLength(1);
|
|
expect(projected.legacySteps[0].stepId).toBe(9);
|
|
expect(projected.legacySteps[0].crossLaneEventIds).toEqual([
|
|
'canonical-production-event',
|
|
]);
|
|
});
|
|
|
|
it('pairs repeated identical steps one-for-one across the two lanes', () => {
|
|
const createdAt = Date.parse('2026-08-05T10:00:00Z') / 1000;
|
|
const legacy = importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'activate_agent',
|
|
data: { value: 1 },
|
|
timestamp: createdAt,
|
|
},
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 10,
|
|
step: 'activate_agent',
|
|
data: { value: 1 },
|
|
timestamp: createdAt + 1,
|
|
},
|
|
]);
|
|
const canonical = [
|
|
normalizeEvent(
|
|
event({
|
|
event_id: 'canonical-1',
|
|
payload: { value: 1 },
|
|
created_at: new Date(createdAt * 1000).toISOString(),
|
|
})
|
|
),
|
|
normalizeEvent(
|
|
event({
|
|
event_id: 'canonical-2',
|
|
cloud_cursor: 2,
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
payload: { value: 1 },
|
|
created_at: new Date((createdAt + 1) * 1000).toISOString(),
|
|
})
|
|
),
|
|
];
|
|
const projected = [...legacy, ...canonical].reduce(
|
|
reduceProjectView,
|
|
createProjectViewState('project-1', 'live')
|
|
);
|
|
|
|
expect(projected.legacySteps).toHaveLength(2);
|
|
expect(
|
|
projected.legacySteps.map((step) => step.crossLaneEventIds?.length)
|
|
).toEqual([1, 1]);
|
|
});
|
|
|
|
it('deduplicates an ask across legacy and canonical lanes by content', () => {
|
|
const legacyAsk = importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'ask',
|
|
data: { content: 'Continue?', agent: 'browser' },
|
|
},
|
|
])[0];
|
|
const canonicalAsk = normalizeEvent(
|
|
event({
|
|
event_id: 'canonical-ask',
|
|
legacy_step: 'ask',
|
|
payload: { agent: 'browser', content: 'Continue?' },
|
|
})
|
|
);
|
|
const first = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
legacyAsk
|
|
);
|
|
const second = reduceProjectView(first, canonicalAsk);
|
|
|
|
expect(second.legacySteps).toHaveLength(1);
|
|
expect(second.legacySteps[0].stepId).toBe(9);
|
|
expect(second.seenEventIds[canonicalAsk.eventId]).toBe(true);
|
|
});
|
|
|
|
it('keeps a legacy pending ask when a canonical frame arrives later', () => {
|
|
const legacyAsk = importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'ask',
|
|
data: { agent: 'browser', content: 'Continue?' },
|
|
},
|
|
])[0];
|
|
const withAsk = reduceProjectView(
|
|
createProjectViewState('project-1', 'live'),
|
|
legacyAsk
|
|
);
|
|
const afterCanonical = reduceProjectView(
|
|
withAsk,
|
|
normalizeEvent(
|
|
event({
|
|
event_id: 'canonical-2',
|
|
cloud_cursor: 1,
|
|
run_sequence: 1,
|
|
legacy_step: 'notice',
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(afterCanonical.legacySteps.map((step) => step.step)).toEqual([
|
|
'ask',
|
|
'notice',
|
|
]);
|
|
expect(afterCanonical.runs['run-1'].lastSequence).toBe(1);
|
|
expect(selectPendingLegacyAsk(afterCanonical, new Set())?.stepId).toBe(9);
|
|
});
|
|
|
|
it('closes a pending ask after a durable human reply', () => {
|
|
const state = projectRawEvents(
|
|
'project-1',
|
|
importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'ask',
|
|
data: { content: 'Continue?' },
|
|
},
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 10,
|
|
step: 'human_reply',
|
|
data: { content: 'Yes' },
|
|
},
|
|
]),
|
|
'live'
|
|
).state;
|
|
|
|
expect(selectPendingLegacyAsk(state, new Set())).toBeNull();
|
|
});
|
|
|
|
it('allows the same question to be asked again after a human reply', () => {
|
|
const state = projectRawEvents(
|
|
'project-1',
|
|
importLegacyChatSteps([
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 9,
|
|
step: 'ask',
|
|
data: { content: 'Continue?' },
|
|
},
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 10,
|
|
step: 'human_reply',
|
|
data: { content: 'Yes' },
|
|
},
|
|
{
|
|
project_id: 'project-1',
|
|
task_id: 'run-1',
|
|
step_id: 11,
|
|
step: 'ask',
|
|
data: { content: 'Continue?' },
|
|
},
|
|
]),
|
|
'live'
|
|
).state;
|
|
|
|
expect(
|
|
state.legacySteps.filter((step) => step.step === 'ask')
|
|
).toHaveLength(2);
|
|
expect(selectPendingLegacyAsk(state, new Set())?.stepId).toBe(11);
|
|
});
|
|
|
|
it('uses snapshot run aggregates even when recent events are truncated', () => {
|
|
const snapshot = projectSnapshot({
|
|
project_id: 'project-1',
|
|
current_cursor: 42,
|
|
runs: [
|
|
{
|
|
run_id: 'run-older',
|
|
status: 'completed',
|
|
expected_next_run_sequence: 17,
|
|
updated_at: '2026-08-05T09:00:00Z',
|
|
},
|
|
],
|
|
recent_events: [],
|
|
events_truncated: true,
|
|
});
|
|
expect(snapshot.runs['run-older']).toEqual({
|
|
runId: 'run-older',
|
|
status: 'completed',
|
|
lastSequence: 16,
|
|
runVersion: 0,
|
|
updatedAt: '2026-08-05T09:00:00Z',
|
|
origin: null,
|
|
resumeBlockedReason: null,
|
|
});
|
|
expect(snapshot.lastSyncedAt).toBeNull();
|
|
});
|
|
|
|
it('preserves legacy raw payload across all V1 importers', () => {
|
|
const legacy = {
|
|
step_id: 9,
|
|
task_id: 'task-1',
|
|
project_id: 'project-1',
|
|
step: 'notice',
|
|
data: { content: 'legacy' },
|
|
timestamp: 123,
|
|
vendor_extension: { keep: true },
|
|
};
|
|
const imported = [
|
|
importLegacyChatSteps([legacy]),
|
|
importIndexedDbV1({ messages: [legacy] }),
|
|
importLocalMemoryV1({ steps: [legacy] }),
|
|
];
|
|
for (const events of imported) {
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].raw).toEqual(legacy);
|
|
expect(events[0].legacyStep).toBe('notice');
|
|
expect(events[0].payload.__legacy_data).toEqual({ content: 'legacy' });
|
|
}
|
|
});
|
|
|
|
it('projects playback without producing real effects', () => {
|
|
const result = projectRawEvents(
|
|
'project-1',
|
|
[event({ event_type: 'run.completed', legacy_step: 'end' })],
|
|
'playback'
|
|
);
|
|
expect(result.state.runs['run-1'].status).toBe('completed');
|
|
expect(result.effects).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
['run.deadline_reached', 'failed'],
|
|
['runtime.interrupted', 'interrupted'],
|
|
] as const)('projects %s as the terminal status %s', (eventType, status) => {
|
|
const result = projectRawEvents(
|
|
'project-1',
|
|
[event({ event_type: eventType, legacy_step: null })],
|
|
'rehydrate'
|
|
);
|
|
|
|
expect(result.state.runs['run-1'].status).toBe(status);
|
|
});
|
|
|
|
it('projects a new attempt as running after an interruption', () => {
|
|
const result = projectRawEvents(
|
|
'project-1',
|
|
[
|
|
event({
|
|
event_id: 'interrupted-1',
|
|
event_type: 'runtime.interrupted',
|
|
legacy_step: null,
|
|
}),
|
|
event({
|
|
event_id: 'attempt-2',
|
|
event_type: 'run.attempt_started',
|
|
legacy_step: null,
|
|
cloud_cursor: 2,
|
|
run_sequence: 2,
|
|
run_version: 2,
|
|
}),
|
|
],
|
|
'rehydrate'
|
|
);
|
|
|
|
expect(result.state.runs['run-1'].status).toBe('running');
|
|
});
|
|
|
|
it('accepts already-normalized importer output without decoding it twice', () => {
|
|
const imported = importLegacyChatSteps([
|
|
{
|
|
step_id: 1,
|
|
task_id: 'run-1',
|
|
project_id: 'project-1',
|
|
step: 'notice',
|
|
data: { content: 'hello' },
|
|
},
|
|
]);
|
|
const result = projectRawEvents(
|
|
'project-1',
|
|
imported,
|
|
'rehydrate',
|
|
createProjectViewState('project-1', 'rehydrate')
|
|
);
|
|
expect(result.state.legacySteps[0].data).toEqual({ content: 'hello' });
|
|
});
|
|
});
|