mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-27 01:22:09 +00:00
212 lines
6 KiB
TypeScript
212 lines
6 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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { fetchGetMock, fetchPostMock } = vi.hoisted(() => ({
|
|
fetchGetMock: vi.fn(),
|
|
fetchPostMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/api/http', () => ({
|
|
fetchGet: fetchGetMock,
|
|
fetchPost: fetchPostMock,
|
|
}));
|
|
|
|
import {
|
|
decideHumanInteraction,
|
|
humanInteractionDecisionPath,
|
|
invalidatePendingHumanInteractions,
|
|
isHumanInteractionStillPending,
|
|
type HumanInteractionPayload,
|
|
} from '@/service/humanInteractionApi';
|
|
|
|
describe('local HumanInteraction API', () => {
|
|
beforeEach(() => {
|
|
fetchPostMock.mockReset();
|
|
fetchGetMock.mockReset();
|
|
invalidatePendingHumanInteractions();
|
|
});
|
|
|
|
it('uses the unprefixed FastAPI Run decision route', async () => {
|
|
fetchPostMock.mockResolvedValue({ status: 'approved' });
|
|
|
|
await decideHumanInteraction(
|
|
{
|
|
interaction_id: 'interaction / 1',
|
|
interaction_type: 'approval',
|
|
run_id: 'run / 1',
|
|
version: 2,
|
|
action_digest: 'a'.repeat(64),
|
|
},
|
|
{
|
|
decisionRequestId: 'decision-1',
|
|
decision: { approved: true },
|
|
actorId: 'user-1',
|
|
}
|
|
);
|
|
|
|
expect(fetchPostMock).toHaveBeenCalledWith(
|
|
'/runs/run%20%2F%201/interactions/interaction%20%2F%201/decisions',
|
|
expect.objectContaining({
|
|
decision_request_id: 'decision-1',
|
|
expected_version: 2,
|
|
source: 'desktop',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('shares the same route builder with the Remote Control bridge', () => {
|
|
expect(humanInteractionDecisionPath('run-1', 'interaction-1')).toBe(
|
|
'/runs/run-1/interactions/interaction-1/decisions'
|
|
);
|
|
});
|
|
|
|
it('revalidates only the exact pending durable interaction', async () => {
|
|
fetchGetMock.mockResolvedValue({
|
|
interactions: [
|
|
{
|
|
interaction_id: 'interaction-1',
|
|
status: 'requested',
|
|
version: 2,
|
|
action_digest: 'a'.repeat(64),
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
isHumanInteractionStillPending({
|
|
interaction_id: 'interaction-1',
|
|
interaction_type: 'approval',
|
|
run_id: 'run / 1',
|
|
version: 2,
|
|
action_digest: 'a'.repeat(64),
|
|
})
|
|
).resolves.toBe(true);
|
|
expect(fetchGetMock).toHaveBeenCalledWith(
|
|
'/runs/run%20%2F%201/interactions?status=pending'
|
|
);
|
|
});
|
|
|
|
it('accepts the lightweight pending-list shape while decision POST still enforces the digest', async () => {
|
|
fetchGetMock.mockResolvedValue({
|
|
interactions: [
|
|
{
|
|
interaction_id: 'interaction-1',
|
|
status: 'requested',
|
|
version: 2,
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
isHumanInteractionStillPending({
|
|
interaction_id: 'interaction-1',
|
|
interaction_type: 'approval',
|
|
run_id: 'run-1',
|
|
version: 2,
|
|
action_digest: 'a'.repeat(64),
|
|
})
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
it('does not unlock a stale version of a pending interaction', async () => {
|
|
fetchGetMock.mockResolvedValue({
|
|
interactions: [
|
|
{
|
|
interaction_id: 'interaction-1',
|
|
status: 'requested',
|
|
version: 3,
|
|
action_digest: 'a'.repeat(64),
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
isHumanInteractionStillPending({
|
|
interaction_id: 'interaction-1',
|
|
interaction_type: 'approval',
|
|
run_id: 'run-1',
|
|
version: 2,
|
|
action_digest: 'a'.repeat(64),
|
|
})
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
it('deduplicates concurrent pending reads for cards from the same Run', async () => {
|
|
fetchGetMock.mockResolvedValue({ interactions: [] });
|
|
const payload = {
|
|
interaction_id: 'interaction-1',
|
|
interaction_type: 'approval' as const,
|
|
run_id: 'run-1',
|
|
version: 0,
|
|
};
|
|
|
|
await Promise.all([
|
|
isHumanInteractionStillPending(payload),
|
|
isHumanInteractionStillPending(payload),
|
|
isHumanInteractionStillPending(payload),
|
|
]);
|
|
|
|
expect(fetchGetMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('posts a decision to the local durable Run route', async () => {
|
|
fetchPostMock.mockResolvedValue({ status: 'resolved' });
|
|
const interaction: HumanInteractionPayload = {
|
|
interaction_id: 'approval:todo/write',
|
|
interaction_type: 'approval',
|
|
run_id: 'run/123',
|
|
version: 4,
|
|
action_digest: 'digest-1',
|
|
};
|
|
|
|
await decideHumanInteraction(interaction, {
|
|
decisionRequestId: 'decision-1',
|
|
decision: { decision: 'approved', scope: 'once' },
|
|
actorId: 42,
|
|
});
|
|
|
|
expect(fetchPostMock).toHaveBeenCalledWith(
|
|
'/runs/run%2F123/interactions/approval%3Atodo%2Fwrite/decisions',
|
|
{
|
|
decision_request_id: 'decision-1',
|
|
decision: { decision: 'approved', scope: 'once' },
|
|
expected_version: 4,
|
|
action_digest: 'digest-1',
|
|
actor_type: 'user',
|
|
actor_id: '42',
|
|
source: 'desktop',
|
|
continue_active_attempt: true,
|
|
}
|
|
);
|
|
});
|
|
|
|
it('rejects an interaction without a durable Run id', async () => {
|
|
await expect(
|
|
decideHumanInteraction(
|
|
{
|
|
interaction_id: 'interaction-1',
|
|
interaction_type: 'approval',
|
|
},
|
|
{
|
|
decisionRequestId: 'decision-1',
|
|
decision: { decision: 'approved', scope: 'once' },
|
|
}
|
|
)
|
|
).rejects.toThrow('Missing durable Run id');
|
|
|
|
expect(fetchPostMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|