mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 17:12:03 +00:00
311 lines
8.8 KiB
TypeScript
311 lines
8.8 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 { fetchGet } from '@/api/http';
|
|
import { normalizeLocalRunEvent } from '@/lib/projector';
|
|
import { reconcileHumanInteractionEvents } from '@/service/humanInteractionEventReconciliation';
|
|
import {
|
|
getProjectEventStore,
|
|
resetProjectEventStoresForTests,
|
|
} from '@/store/projectEventStore';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@/api/http', () => ({ fetchGet: vi.fn() }));
|
|
|
|
const fetchGetMock = vi.mocked(fetchGet);
|
|
|
|
function localEvent(
|
|
sequence: number,
|
|
eventType: string,
|
|
payload: Record<string, unknown>,
|
|
eventId = `${eventType}-${sequence}`
|
|
) {
|
|
return {
|
|
event_id: eventId,
|
|
run_id: 'run-1',
|
|
sequence,
|
|
run_version: sequence,
|
|
event_type: eventType,
|
|
legacy_step: null,
|
|
payload,
|
|
created_at: `2026-08-11T10:00:${String(sequence).padStart(2, '0')}.000Z`,
|
|
};
|
|
}
|
|
|
|
describe('reconcileHumanInteractionEvents', () => {
|
|
beforeEach(() => {
|
|
fetchGetMock.mockReset();
|
|
resetProjectEventStoresForTests();
|
|
});
|
|
|
|
afterEach(() => resetProjectEventStoresForTests());
|
|
|
|
it('paginates from the request sequence and enqueues the durable terminal event', async () => {
|
|
const store = getProjectEventStore('project-1');
|
|
store.enqueue(
|
|
normalizeLocalRunEvent(
|
|
localEvent(1, 'interaction.requested', {
|
|
interaction_id: 'choice-1',
|
|
interaction_type: 'choice',
|
|
options: [{ id: 'a', label: 'A' }],
|
|
}),
|
|
'project-1'
|
|
)
|
|
);
|
|
store.flushAll();
|
|
fetchGetMock
|
|
.mockResolvedValueOnce({
|
|
run_id: 'run-1',
|
|
next_sequence: 2,
|
|
has_more: true,
|
|
events: [localEvent(2, 'activity.updated', { label: 'Working' })],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
run_id: 'run-1',
|
|
next_sequence: 3,
|
|
has_more: false,
|
|
events: [
|
|
localEvent(3, 'interaction.resolved', {
|
|
interaction_id: 'choice-1',
|
|
interaction_type: 'choice',
|
|
decision: { option_id: 'a' },
|
|
}),
|
|
],
|
|
});
|
|
|
|
const terminal = await reconcileHumanInteractionEvents({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'choice-1',
|
|
afterSequence: 1,
|
|
});
|
|
|
|
expect(terminal.eventType).toBe('interaction.resolved');
|
|
expect(fetchGetMock).toHaveBeenNthCalledWith(1, '/runs/run-1/events', {
|
|
after_sequence: 1,
|
|
limit: 500,
|
|
});
|
|
expect(fetchGetMock).toHaveBeenNthCalledWith(2, '/runs/run-1/events', {
|
|
after_sequence: 2,
|
|
limit: 500,
|
|
});
|
|
expect(store.getControlSnapshot().interactionById['choice-1'].status).toBe(
|
|
'resolved'
|
|
);
|
|
});
|
|
|
|
it('does not synthesize resolution from the successful command response', async () => {
|
|
const store = getProjectEventStore('project-1');
|
|
store.enqueue(
|
|
normalizeLocalRunEvent(
|
|
localEvent(1, 'approval.requested', {
|
|
approval_id: 'approval-1',
|
|
interaction_type: 'approval',
|
|
allowed_scopes: ['once'],
|
|
action_digest: 'digest-1',
|
|
}),
|
|
'project-1'
|
|
)
|
|
);
|
|
store.flushAll();
|
|
fetchGetMock.mockResolvedValue({
|
|
run_id: 'run-1',
|
|
next_sequence: 1,
|
|
has_more: false,
|
|
events: [],
|
|
});
|
|
|
|
await expect(
|
|
reconcileHumanInteractionEvents({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'approval-1',
|
|
afterSequence: 1,
|
|
})
|
|
).rejects.toThrow('durable lifecycle event');
|
|
|
|
expect(
|
|
store.getControlSnapshot().interactionById['approval-1'].status
|
|
).toBe('requested');
|
|
});
|
|
|
|
it('does not resolve until the terminal event reaches HumanControlProjection', async () => {
|
|
let scheduledFlush: (() => void) | null = null;
|
|
const store = getProjectEventStore('project-1', {
|
|
scheduleFlush: (callback) => {
|
|
scheduledFlush = callback;
|
|
return () => {
|
|
if (scheduledFlush === callback) scheduledFlush = null;
|
|
};
|
|
},
|
|
});
|
|
store.enqueue(
|
|
normalizeLocalRunEvent(
|
|
localEvent(1, 'interaction.requested', {
|
|
interaction_id: 'question-1',
|
|
interaction_type: 'question',
|
|
request: { question: 'Which file?' },
|
|
}),
|
|
'project-1'
|
|
)
|
|
);
|
|
store.flushAll();
|
|
fetchGetMock.mockResolvedValue({
|
|
run_id: 'run-1',
|
|
next_sequence: 2,
|
|
has_more: false,
|
|
events: [
|
|
localEvent(2, 'interaction.resolved', {
|
|
interaction_id: 'question-1',
|
|
interaction_type: 'question',
|
|
decision: { reply: 'report.md' },
|
|
}),
|
|
],
|
|
});
|
|
|
|
let resolved = false;
|
|
const reconciliation = reconcileHumanInteractionEvents({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'question-1',
|
|
afterSequence: 1,
|
|
projectionTimeoutMs: 1_000,
|
|
}).then((event) => {
|
|
resolved = true;
|
|
return event;
|
|
});
|
|
await vi.waitFor(() => expect(scheduledFlush).not.toBeNull());
|
|
|
|
expect(resolved).toBe(false);
|
|
expect(
|
|
store.getControlSnapshot().interactionById['question-1'].status
|
|
).toBe('requested');
|
|
const flush = scheduledFlush;
|
|
if (!flush) throw new Error('expected a scheduled projection flush');
|
|
flush();
|
|
|
|
await expect(reconciliation).resolves.toMatchObject({
|
|
eventType: 'interaction.resolved',
|
|
});
|
|
expect(
|
|
store.getControlSnapshot().interactionById['question-1'].status
|
|
).toBe('resolved');
|
|
});
|
|
|
|
it('rejects when the bounded Project event queue overflows', async () => {
|
|
const store = getProjectEventStore('project-1', {
|
|
maxQueueEvents: 1,
|
|
scheduleFlush: () => () => undefined,
|
|
});
|
|
store.enqueue(
|
|
normalizeLocalRunEvent(
|
|
localEvent(1, 'interaction.requested', {
|
|
interaction_id: 'question-1',
|
|
interaction_type: 'question',
|
|
}),
|
|
'project-1'
|
|
)
|
|
);
|
|
store.flushAll();
|
|
fetchGetMock.mockResolvedValue({
|
|
run_id: 'run-1',
|
|
next_sequence: 3,
|
|
has_more: false,
|
|
events: [
|
|
localEvent(2, 'activity.updated', { label: 'Working' }),
|
|
localEvent(3, 'interaction.resolved', {
|
|
interaction_id: 'question-1',
|
|
interaction_type: 'question',
|
|
}),
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
reconcileHumanInteractionEvents({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'question-1',
|
|
afterSequence: 1,
|
|
})
|
|
).rejects.toThrow('queue rejected');
|
|
expect(store.getSnapshot().overflowed).toBe(true);
|
|
expect(
|
|
store.getControlSnapshot().interactionById['question-1'].status
|
|
).toBe('requested');
|
|
});
|
|
|
|
it('rejects a projected interaction that does not belong to the requested Run', async () => {
|
|
const store = getProjectEventStore('project-1');
|
|
store.enqueue(
|
|
normalizeLocalRunEvent(
|
|
{
|
|
...localEvent(1, 'interaction.requested', {
|
|
interaction_id: 'shared-id',
|
|
interaction_type: 'question',
|
|
}),
|
|
run_id: 'run-2',
|
|
},
|
|
'project-1'
|
|
)
|
|
);
|
|
store.flushAll();
|
|
fetchGetMock.mockResolvedValue({
|
|
run_id: 'run-1',
|
|
next_sequence: 1,
|
|
has_more: false,
|
|
events: [
|
|
localEvent(1, 'interaction.resolved', {
|
|
interaction_id: 'shared-id',
|
|
interaction_type: 'question',
|
|
}),
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
reconcileHumanInteractionEvents({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'shared-id',
|
|
afterSequence: 0,
|
|
projectionTimeoutMs: 200,
|
|
})
|
|
).rejects.toThrow('cross-scope interaction');
|
|
});
|
|
|
|
it('rejects a replay event carrying a different Project scope', async () => {
|
|
getProjectEventStore('project-1');
|
|
fetchGetMock.mockResolvedValue({
|
|
run_id: 'run-1',
|
|
next_sequence: 1,
|
|
has_more: false,
|
|
events: [
|
|
{
|
|
...localEvent(1, 'interaction.resolved', {
|
|
interaction_id: 'question-1',
|
|
}),
|
|
project_id: 'project-2',
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
reconcileHumanInteractionEvents({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'question-1',
|
|
afterSequence: 0,
|
|
})
|
|
).rejects.toThrow('cross-Project event');
|
|
});
|
|
});
|