eigent/test/unit/service/humanInteractionApi.test.ts
Douglas Lai 05d4f7ef3d
Feat/refactor chatbox event bus (#1852)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-18 23:40:29 +08:00

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();
});
});