mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-27 01:22:09 +00:00
606 lines
18 KiB
TypeScript
606 lines
18 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 {
|
|
getStableDecisionRequestId,
|
|
STABLE_DECISION_REQUEST_ID_CACHE_LIMIT,
|
|
useEventNativeHumanControl,
|
|
} from '@/components/ChatBox/BottomBox/useEventNativeHumanControl';
|
|
import type {
|
|
HumanControlInteraction,
|
|
HumanControlProjectionState,
|
|
} from '@/lib/projector/control';
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
projection: null as HumanControlProjectionState | null,
|
|
decide: vi.fn(),
|
|
reconcile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/hooks/useProjectEventView', () => ({
|
|
useProjectHumanControlProjection: () => mocks.projection,
|
|
}));
|
|
|
|
vi.mock('@/service/humanInteractionApi', () => ({
|
|
decideHumanInteraction: mocks.decide,
|
|
}));
|
|
|
|
vi.mock('@/service/humanInteractionEventReconciliation', () => ({
|
|
reconcileHumanInteractionEvents: mocks.reconcile,
|
|
}));
|
|
|
|
vi.mock('@/store/authStore', () => ({
|
|
useAuthStore: (selector: (state: { user_id: number }) => unknown) =>
|
|
selector({ user_id: 42 }),
|
|
}));
|
|
|
|
function interaction(
|
|
overrides: Partial<HumanControlInteraction> = {}
|
|
): HumanControlInteraction {
|
|
return {
|
|
interactionId: 'approval-1',
|
|
interactionType: 'approval',
|
|
status: 'requested',
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
sequence: 3,
|
|
lastSequence: 3,
|
|
cloudCursor: null,
|
|
lastCloudCursor: null,
|
|
requestEventId: 'request-1',
|
|
requestSource: 'canonical',
|
|
lastEventId: 'request-1',
|
|
requestedAt: '2026-08-11T10:00:03.000Z',
|
|
updatedAt: '2026-08-11T10:00:03.000Z',
|
|
version: 0,
|
|
approvalId: 'approval-1',
|
|
actionDigest: 'digest-1',
|
|
allowedScopes: ['once'],
|
|
title: 'Allow todo_write?',
|
|
prompt: 'The agent wants to write a todo.',
|
|
agent: 'developer_agent',
|
|
operation: 'mcp.tool.write',
|
|
targetResources: [],
|
|
displayArguments: {},
|
|
ruleMatcher: null,
|
|
options: [],
|
|
fields: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function projection(
|
|
interactions: HumanControlInteraction[]
|
|
): HumanControlProjectionState {
|
|
return {
|
|
projectId: 'project-1',
|
|
interactionById: Object.fromEntries(
|
|
interactions.map((item) => [item.interactionId, item])
|
|
),
|
|
orderedInteractionIds: interactions.map((item) => item.interactionId),
|
|
seenEventIds: {},
|
|
};
|
|
}
|
|
|
|
describe('useEventNativeHumanControl', () => {
|
|
beforeEach(() => {
|
|
sessionStorage.clear();
|
|
mocks.decide.mockReset();
|
|
mocks.reconcile.mockReset();
|
|
mocks.decide.mockResolvedValue({ status: 'resolved' });
|
|
mocks.reconcile.mockResolvedValue({ eventType: 'approval.decided' });
|
|
mocks.projection = projection([]);
|
|
});
|
|
|
|
it('selects the oldest pending control only from the explicit active Run', () => {
|
|
mocks.projection = projection([
|
|
interaction({ interactionId: 'other-run', runId: 'run-2' }),
|
|
interaction({ interactionId: 'oldest', sequence: 4 }),
|
|
interaction({ interactionId: 'newer', sequence: 8 }),
|
|
]);
|
|
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
expect(result.current.interaction?.interactionId).toBe('oldest');
|
|
expect(result.current.pendingCount).toBe(2);
|
|
});
|
|
|
|
it('maps approval scopes exactly as offered and reconciles through durable events', async () => {
|
|
mocks.projection = projection([
|
|
interaction({ allowedScopes: ['once', 'organization', 'space'] }),
|
|
]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
expect(result.current.variant?.kind).toBe('approval');
|
|
if (result.current.variant?.kind !== 'approval') throw new Error('variant');
|
|
expect(
|
|
result.current.variant.options.map((option) => option.scope)
|
|
).toEqual(['once', 'space']);
|
|
|
|
act(
|
|
() =>
|
|
result.current.variant?.kind === 'approval' &&
|
|
result.current.variant.onApprove('once')
|
|
);
|
|
|
|
await waitFor(() => expect(mocks.reconcile).toHaveBeenCalledOnce());
|
|
expect(mocks.decide).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
interaction_id: 'approval-1',
|
|
run_id: 'run-1',
|
|
version: 0,
|
|
action_digest: 'digest-1',
|
|
}),
|
|
{
|
|
decisionRequestId: expect.any(String),
|
|
decision: { decision: 'approved', scope: 'once' },
|
|
actorId: 42,
|
|
}
|
|
);
|
|
expect(mocks.reconcile).toHaveBeenCalledWith({
|
|
projectId: 'project-1',
|
|
runId: 'run-1',
|
|
interactionId: 'approval-1',
|
|
afterSequence: 3,
|
|
});
|
|
expect(result.current.phase).toBe('reconciling');
|
|
expect(result.current.variant.submitting).toBe(true);
|
|
});
|
|
|
|
it('notifies compatibility state only after a durable resolution is loaded', async () => {
|
|
const onDurableResolution = vi.fn();
|
|
mocks.projection = projection([interaction()]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
onDurableResolution,
|
|
})
|
|
);
|
|
|
|
act(() => {
|
|
if (result.current.variant?.kind === 'approval') {
|
|
result.current.variant.onApprove('once');
|
|
}
|
|
});
|
|
|
|
await waitFor(() => expect(onDurableResolution).toHaveBeenCalledOnce());
|
|
expect(mocks.reconcile.mock.invocationCallOrder[0]).toBeLessThan(
|
|
onDurableResolution.mock.invocationCallOrder[0]
|
|
);
|
|
expect(onDurableResolution).toHaveBeenCalledWith(
|
|
expect.objectContaining({ interactionId: 'approval-1' })
|
|
);
|
|
});
|
|
|
|
it('does not use a legacy connection ordinal as a durable replay cursor', async () => {
|
|
mocks.projection = projection([
|
|
interaction({ sequence: 48, requestSource: 'chat_step_v1' }),
|
|
]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
act(() => {
|
|
if (result.current.variant?.kind === 'approval') {
|
|
result.current.variant.onApprove('once');
|
|
}
|
|
});
|
|
|
|
await waitFor(() => expect(mocks.reconcile).toHaveBeenCalledOnce());
|
|
expect(mocks.reconcile).toHaveBeenCalledWith(
|
|
expect.objectContaining({ afterSequence: 0 })
|
|
);
|
|
});
|
|
|
|
it('keeps a failed decision retryable with the same request id across remount', async () => {
|
|
const consoleError = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => undefined);
|
|
mocks.projection = projection([interaction()]);
|
|
mocks.decide
|
|
.mockRejectedValueOnce(new Error('network response lost'))
|
|
.mockResolvedValueOnce({ status: 'resolved' });
|
|
|
|
const first = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
act(() => {
|
|
if (first.result.current.variant?.kind === 'approval') {
|
|
first.result.current.variant.onApprove('once');
|
|
}
|
|
});
|
|
await waitFor(() => expect(first.result.current.submitError).toBeTruthy());
|
|
const firstRequestId = mocks.decide.mock.calls[0][1].decisionRequestId;
|
|
expect(first.result.current.phase).toBe('idle');
|
|
first.unmount();
|
|
|
|
const second = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
act(() => {
|
|
if (second.result.current.variant?.kind === 'approval') {
|
|
second.result.current.variant.onApprove('once');
|
|
}
|
|
});
|
|
await waitFor(() => expect(mocks.decide).toHaveBeenCalledTimes(2));
|
|
|
|
expect(mocks.decide.mock.calls[1][1].decisionRequestId).toBe(
|
|
firstRequestId
|
|
);
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it('maps known controls and fails closed for unknown semantic types', () => {
|
|
const cases = [
|
|
interaction({
|
|
interactionId: 'choice-1',
|
|
interactionType: 'choice',
|
|
options: [{ id: 'a', label: 'Branch A', value: { branch: 'a' } }],
|
|
}),
|
|
interaction({
|
|
interactionId: 'form-1',
|
|
interactionType: 'form',
|
|
fields: [
|
|
{ id: 'reason', label: 'Reason', required: true, type: 'textarea' },
|
|
],
|
|
}),
|
|
interaction({
|
|
interactionId: 'question-1',
|
|
interactionType: 'question',
|
|
}),
|
|
interaction({
|
|
interactionId: 'future-1',
|
|
interactionType: 'future_review',
|
|
}),
|
|
];
|
|
|
|
const kinds = cases.map((item) => {
|
|
mocks.projection = projection([item]);
|
|
const hook = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
const kind = hook.result.current.variant?.kind;
|
|
hook.unmount();
|
|
return kind;
|
|
});
|
|
|
|
expect(kinds).toEqual(['selection', 'form', 'feedback', 'blocked']);
|
|
});
|
|
|
|
it('submits the backend merge-conflict options as a durable selection', async () => {
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: 'merge-conflict:abc',
|
|
interactionType: 'merge_conflict',
|
|
title: 'Agent changes need conflict resolution',
|
|
targetResources: ['src/report.ts'],
|
|
options: [
|
|
{ id: 'keep_run', label: 'Keep Run version', value: 'keep_run' },
|
|
{
|
|
id: 'take_agent',
|
|
label: 'Use Agent version',
|
|
value: 'take_agent',
|
|
},
|
|
{ id: 'manual', label: 'Resolve manually', value: 'manual' },
|
|
],
|
|
}),
|
|
]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
expect(result.current.variant?.kind).toBe('selection');
|
|
act(() => {
|
|
if (result.current.variant?.kind === 'selection') {
|
|
result.current.variant.onSelectionChange(['take_agent']);
|
|
}
|
|
});
|
|
await waitFor(() =>
|
|
expect(
|
|
result.current.variant?.kind === 'selection'
|
|
? result.current.variant.selectedIds
|
|
: []
|
|
).toEqual(['take_agent'])
|
|
);
|
|
act(() => {
|
|
if (result.current.variant?.kind === 'selection') {
|
|
result.current.variant.onSubmit();
|
|
}
|
|
});
|
|
|
|
await waitFor(() => expect(mocks.reconcile).toHaveBeenCalledOnce());
|
|
expect(mocks.decide).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
interaction_id: 'merge-conflict:abc',
|
|
interaction_type: 'merge_conflict',
|
|
run_id: 'run-1',
|
|
}),
|
|
expect.objectContaining({
|
|
decision: { option_id: 'take_agent', value: 'take_agent' },
|
|
})
|
|
);
|
|
expect(mocks.reconcile).toHaveBeenCalledWith(
|
|
expect.objectContaining({ interactionId: 'merge-conflict:abc' })
|
|
);
|
|
});
|
|
|
|
it('submits the backend question interaction as a durable reply', async () => {
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: 'question-1',
|
|
interactionType: 'question',
|
|
prompt: 'Which file should I update?',
|
|
approvalId: undefined,
|
|
actionDigest: undefined,
|
|
}),
|
|
]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
expect(result.current.variant).toMatchObject({
|
|
kind: 'feedback',
|
|
header: {
|
|
title: 'Allow todo_write?',
|
|
description: 'Which file should I update?',
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
if (result.current.variant?.kind === 'feedback') {
|
|
result.current.variant.onChange('src/report.ts');
|
|
}
|
|
});
|
|
await waitFor(() =>
|
|
expect(
|
|
result.current.variant?.kind === 'feedback'
|
|
? result.current.variant.value
|
|
: ''
|
|
).toBe('src/report.ts')
|
|
);
|
|
act(() => {
|
|
if (result.current.variant?.kind === 'feedback') {
|
|
result.current.variant.onSubmit();
|
|
}
|
|
});
|
|
|
|
await waitFor(() => expect(mocks.reconcile).toHaveBeenCalledOnce());
|
|
expect(mocks.decide).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
interaction_id: 'question-1',
|
|
interaction_type: 'question',
|
|
}),
|
|
expect.objectContaining({ decision: { reply: 'src/report.ts' } })
|
|
);
|
|
expect(mocks.reconcile).toHaveBeenCalledWith(
|
|
expect.objectContaining({ interactionId: 'question-1' })
|
|
);
|
|
});
|
|
|
|
it('blocks option interactions that omit backend-provided options', () => {
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: 'merge-conflict:empty',
|
|
interactionType: 'merge_conflict',
|
|
options: [],
|
|
}),
|
|
]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
expect(result.current.variant).toMatchObject({ kind: 'blocked' });
|
|
expect(mocks.decide).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps diff review and credential binding fail-closed', () => {
|
|
const kinds = ['diff_review', 'credential_binding'].map(
|
|
(interactionType) => {
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: interactionType,
|
|
interactionType,
|
|
}),
|
|
]);
|
|
const hook = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
const kind = hook.result.current.variant?.kind;
|
|
hook.unmount();
|
|
return kind;
|
|
}
|
|
);
|
|
|
|
expect(kinds).toEqual(['blocked', 'blocked']);
|
|
expect(mocks.decide).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks password and unknown form fields instead of exposing text inputs', () => {
|
|
const fieldTypes = ['password', 'file'];
|
|
const kinds = fieldTypes.map((type, index) => {
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: `unsafe-form-${index}`,
|
|
interactionType: 'form',
|
|
fields: [
|
|
{
|
|
id: 'sensitive-value',
|
|
label: 'Sensitive value',
|
|
required: true,
|
|
type,
|
|
},
|
|
],
|
|
}),
|
|
]);
|
|
const hook = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
const variant = hook.result.current.variant;
|
|
hook.unmount();
|
|
return variant;
|
|
});
|
|
|
|
expect(kinds).toEqual([
|
|
expect.objectContaining({ kind: 'blocked' }),
|
|
expect.objectContaining({ kind: 'blocked' }),
|
|
]);
|
|
expect(mocks.decide).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not submit a frontend-synthesized legacy interaction id', () => {
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: 'legacy:run-1:legacy-ask-1',
|
|
interactionType: 'question',
|
|
requestSource: 'chat_step_v1',
|
|
}),
|
|
]);
|
|
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
expect(result.current.variant).toMatchObject({ kind: 'blocked' });
|
|
expect(mocks.decide).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not expose controls when the owning Run is read-only', () => {
|
|
mocks.projection = projection([interaction()]);
|
|
const { result } = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
enabled: false,
|
|
})
|
|
);
|
|
|
|
expect(result.current.interaction).toBeNull();
|
|
expect(result.current.variant).toBeNull();
|
|
expect(result.current.pendingCount).toBe(0);
|
|
});
|
|
|
|
it('uses a new id when a retry changes the actual decision', () => {
|
|
const approval = interaction();
|
|
const approvedId = getStableDecisionRequestId('project-1', approval, {
|
|
decision: 'approved',
|
|
scope: 'once',
|
|
});
|
|
const rejectedId = getStableDecisionRequestId('project-1', approval, {
|
|
decision: 'rejected',
|
|
scope: 'once',
|
|
});
|
|
|
|
expect(rejectedId).not.toBe(approvedId);
|
|
});
|
|
|
|
it('bounds stable request ids in memory and session storage', () => {
|
|
const decision = { decision: 'approved', scope: 'once' };
|
|
const firstInteraction = interaction({ interactionId: 'cache-0' });
|
|
const firstId = getStableDecisionRequestId(
|
|
'project-1',
|
|
firstInteraction,
|
|
decision
|
|
);
|
|
|
|
for (
|
|
let index = 1;
|
|
index <= STABLE_DECISION_REQUEST_ID_CACHE_LIMIT;
|
|
index += 1
|
|
) {
|
|
getStableDecisionRequestId(
|
|
'project-1',
|
|
interaction({ interactionId: `cache-${index}` }),
|
|
decision
|
|
);
|
|
}
|
|
|
|
// One additional key stores the LRU index itself.
|
|
expect(sessionStorage.length).toBeLessThanOrEqual(
|
|
STABLE_DECISION_REQUEST_ID_CACHE_LIMIT + 1
|
|
);
|
|
expect(
|
|
getStableDecisionRequestId('project-1', firstInteraction, decision)
|
|
).not.toBe(firstId);
|
|
});
|
|
|
|
it('releases cached request ids once the durable control is terminal', async () => {
|
|
const decision = { decision: 'approved', scope: 'once' };
|
|
const pending = interaction({ interactionId: 'terminal-cleanup' });
|
|
const firstId = getStableDecisionRequestId('project-1', pending, decision);
|
|
mocks.projection = projection([pending]);
|
|
const hook = renderHook(() =>
|
|
useEventNativeHumanControl({
|
|
projectId: 'project-1',
|
|
activeRunId: 'run-1',
|
|
})
|
|
);
|
|
|
|
mocks.projection = projection([
|
|
interaction({
|
|
interactionId: 'terminal-cleanup',
|
|
status: 'resolved',
|
|
lastSequence: 4,
|
|
lastEventId: 'decision-1',
|
|
}),
|
|
]);
|
|
await act(async () => hook.rerender());
|
|
|
|
expect(getStableDecisionRequestId('project-1', pending, decision)).not.toBe(
|
|
firstId
|
|
);
|
|
});
|
|
});
|