mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 00:52:07 +00:00
364 lines
12 KiB
TypeScript
364 lines
12 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';
|
|
|
|
vi.mock('@/api/http', () => ({
|
|
fetchDelete: vi.fn(),
|
|
fetchGet: vi.fn(() => Promise.resolve({ items: [] })),
|
|
fetchPost: vi.fn((path: string, body?: Record<string, unknown>) => {
|
|
if (path.endsWith('/follow-ups')) {
|
|
const requestId = String(body?.request_id || 'request-1');
|
|
return Promise.resolve({
|
|
request_id: requestId,
|
|
project_id: path.split('/')[2],
|
|
content: String(body?.content || ''),
|
|
attachment_paths: body?.attachment_paths || [],
|
|
delivery_mode: 'wait',
|
|
status: 'pending',
|
|
source: body?.source || 'local',
|
|
source_command_id: body?.source_command_id || null,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
});
|
|
}
|
|
return Promise.resolve({});
|
|
}),
|
|
fetchPut: vi.fn(),
|
|
getBaseURL: vi.fn(() => Promise.resolve('')),
|
|
getLocalControlCapability: vi.fn(() =>
|
|
Promise.resolve('renderer-capability')
|
|
),
|
|
proxyFetchGet: vi.fn(() => Promise.resolve({ items: [] })),
|
|
proxyFetchPost: vi.fn(() => Promise.resolve({ id: 'history-id' })),
|
|
proxyFetchPut: vi.fn(),
|
|
sseTransport: vi.fn(() => Promise.resolve()),
|
|
uploadFile: vi.fn(),
|
|
waitForBackendReady: vi.fn(() => Promise.resolve(true)),
|
|
}));
|
|
|
|
import { fetchGet, fetchPost } from '@/api/http';
|
|
import {
|
|
__remoteControlBridgeTestHooks,
|
|
ackFromDurableExecution,
|
|
} from '@/hooks/useRemoteControlBridge';
|
|
import { useProjectStore } from '@/store/projectStore';
|
|
import { SPACE_SCHEMA_VERSION, useSpaceStore } from '@/store/spaceStore';
|
|
|
|
describe('useRemoteControlBridge internals', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(fetchPost).mockImplementation(async (_url, body: any) => ({
|
|
request_id: body?.request_id || 'request-id',
|
|
project_id: 'project-target',
|
|
content: body?.content || '',
|
|
attachment_paths: body?.attachment_paths || [],
|
|
delivery_mode: 'wait',
|
|
status: 'pending',
|
|
source: body?.source || 'remote_control',
|
|
source_command_id: body?.source_command_id || null,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
}));
|
|
useProjectStore.setState({
|
|
activeProjectId: null,
|
|
projects: {},
|
|
navLeadByProjectId: {},
|
|
historyLoadingProjectIds: {},
|
|
staleProjectIds: new Set(),
|
|
});
|
|
useSpaceStore.setState({
|
|
activeSpaceId: 'space-active',
|
|
spaces: {
|
|
'space-active': {
|
|
id: 'space-active',
|
|
name: 'Active Space',
|
|
sourceType: 'blank',
|
|
status: 'active',
|
|
schemaVersion: SPACE_SCHEMA_VERSION,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
},
|
|
lastVisitedProjectBySpace: {},
|
|
projectsBySpaceId: {},
|
|
projectIdIndex: {},
|
|
projectsSyncedAt: {},
|
|
});
|
|
});
|
|
|
|
it('allows user_message commands for a non-active Project without switching foreground Project', async () => {
|
|
const activeProjectId = useProjectStore
|
|
.getState()
|
|
.createProject('Active Project', undefined, 'project-active');
|
|
|
|
vi.mocked(fetchGet).mockImplementation(async (url) =>
|
|
url === '/projects/project-target/follow-ups'
|
|
? {
|
|
items: [
|
|
{
|
|
request_id: 'task-target-next',
|
|
project_id: 'project-target',
|
|
content: 'Continue the target project in the background',
|
|
attachment_paths: [],
|
|
delivery_mode: 'wait',
|
|
status: 'pending',
|
|
source: 'remote_control',
|
|
source_command_id: 'rc_cmd_cross_project',
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
],
|
|
}
|
|
: { has_lock: true, status: 'running' }
|
|
);
|
|
|
|
const ack = await __remoteControlBridgeTestHooks.executeRemoteCommand(
|
|
{
|
|
id: 'rc_cmd_cross_project',
|
|
session_id: 'session-1',
|
|
user_id: 1,
|
|
source_channel: 'remote_control',
|
|
type: 'user_message',
|
|
target_project_id: 'project-target',
|
|
payload: {
|
|
content: 'Continue the target project in the background',
|
|
project_name: 'Target Project',
|
|
space_id: 'space-active',
|
|
},
|
|
next_task_id: 'task-target-next',
|
|
},
|
|
'token'
|
|
);
|
|
|
|
expect(ack).toMatchObject({
|
|
type: 'command_ack',
|
|
command_id: 'rc_cmd_cross_project',
|
|
status: 'acknowledged',
|
|
result: { queued: true },
|
|
});
|
|
expect(useProjectStore.getState().activeProjectId).toBe(activeProjectId);
|
|
expect(useProjectStore.getState().projects['project-target']).toBeDefined();
|
|
expect(fetchGet).toHaveBeenCalledWith(
|
|
'/projects/project-target/follow-ups'
|
|
);
|
|
expect(fetchPost).not.toHaveBeenCalledWith(
|
|
'/chat/project-target',
|
|
expect.anything()
|
|
);
|
|
expect(fetchGet).toHaveBeenCalledWith('/chat/project-target/status');
|
|
});
|
|
|
|
it('starts local user_message tasks against the target Project without switching foreground Project', async () => {
|
|
const activeProjectId = useProjectStore
|
|
.getState()
|
|
.createProject('Active Project', undefined, 'project-active');
|
|
useProjectStore
|
|
.getState()
|
|
.createProject(
|
|
'Target Project',
|
|
undefined,
|
|
'project-target',
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
{ spaceId: 'space-active', mode: 'single-agent' }
|
|
);
|
|
const targetChatStore = useProjectStore
|
|
.getState()
|
|
.getChatStore('project-target');
|
|
const startTask = vi.fn(() => Promise.resolve());
|
|
targetChatStore?.setState({ startTask } as any);
|
|
|
|
vi.mocked(fetchGet).mockImplementation(async (url) =>
|
|
url === '/projects/project-target/follow-ups'
|
|
? {
|
|
items: [
|
|
{
|
|
request_id: 'task-target-next',
|
|
project_id: 'project-target',
|
|
content: 'Start local background task',
|
|
attachment_paths: [],
|
|
delivery_mode: 'wait',
|
|
status: 'pending',
|
|
source: 'remote_control',
|
|
source_command_id: 'rc_cmd_local_start',
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
],
|
|
}
|
|
: { has_lock: false, status: 'idle' }
|
|
);
|
|
|
|
const ack = await __remoteControlBridgeTestHooks.executeRemoteCommand(
|
|
{
|
|
id: 'rc_cmd_local_start',
|
|
session_id: 'session-1',
|
|
user_id: 1,
|
|
source_channel: 'remote_control',
|
|
type: 'user_message',
|
|
target_project_id: 'project-target',
|
|
payload: {
|
|
content: 'Start local background task',
|
|
project_name: 'Target Project',
|
|
space_id: 'space-active',
|
|
},
|
|
next_task_id: 'task-target-next',
|
|
},
|
|
'token'
|
|
);
|
|
|
|
expect(ack).toMatchObject({
|
|
type: 'command_ack',
|
|
command_id: 'rc_cmd_local_start',
|
|
status: 'acknowledged',
|
|
});
|
|
expect(useProjectStore.getState().activeProjectId).toBe(activeProjectId);
|
|
expect(startTask).toHaveBeenCalledTimes(1);
|
|
expect(startTask.mock.calls[0]?.[0]).toBe('task-target-next');
|
|
expect(startTask.mock.calls[0]?.[4]).toBe('Start local background task');
|
|
expect(startTask.mock.calls[0]?.[7]).toBe('project-target');
|
|
expect(startTask.mock.calls[0]?.[8]).toBe('single-agent');
|
|
expect(startTask.mock.calls[0]?.[9]).toMatchObject({
|
|
preserveTaskId: true,
|
|
skipHistoryCreate: false,
|
|
historyId: null,
|
|
});
|
|
expect(fetchGet).toHaveBeenCalledWith(
|
|
'/projects/project-target/follow-ups'
|
|
);
|
|
expect(fetchGet).toHaveBeenCalledWith('/chat/project-target/status');
|
|
});
|
|
|
|
it('keeps remote history metadata on inactive background Projects', () => {
|
|
const activeProjectId = useProjectStore
|
|
.getState()
|
|
.createProject('Active Project', undefined, 'project-active');
|
|
|
|
__remoteControlBridgeTestHooks.ensureRemoteProjectLoaded({
|
|
id: 'rc_cmd_history_meta',
|
|
session_id: 'session-1',
|
|
user_id: 1,
|
|
source_channel: 'remote_control',
|
|
type: 'user_message',
|
|
target_project_id: 'project-target',
|
|
payload: {
|
|
content: 'Start local background task',
|
|
project_name: 'Target Project',
|
|
space_id: 'space-active',
|
|
history_id: 'legacy-history-id',
|
|
remote_history_id: 'remote-history-id',
|
|
},
|
|
next_task_id: 'task-target-next',
|
|
});
|
|
|
|
const project = useProjectStore.getState().projects['project-target'];
|
|
expect(useProjectStore.getState().activeProjectId).toBe(activeProjectId);
|
|
expect(project).toBeDefined();
|
|
expect(project.metadata?.historyId).toBe('remote-history-id');
|
|
expect(project.metadata?.remoteHistoryHydrationPending).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('remote command durable ACK replay', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it('treats a device owner mismatch as a terminal bridge error', () => {
|
|
expect(
|
|
__remoteControlBridgeTestHooks.serverBridgeError({
|
|
type: 'error',
|
|
code: 'device_owner_mismatch',
|
|
message: 'Desktop device belongs to another user',
|
|
retryable: false,
|
|
})
|
|
).toEqual({
|
|
code: 'device_owner_mismatch',
|
|
message: 'Desktop device belongs to another user',
|
|
retryable: false,
|
|
});
|
|
});
|
|
|
|
it('replays the canonical completed outcome without executing again', () => {
|
|
expect(
|
|
ackFromDurableExecution('command-1', {
|
|
event_type: 'execution.completed',
|
|
payload: { result: { run_id: 'run-1' } },
|
|
})
|
|
).toEqual({
|
|
type: 'command_ack',
|
|
command_id: 'command-1',
|
|
status: 'acknowledged',
|
|
result: { run_id: 'run-1' },
|
|
replayed_from_cache: true,
|
|
});
|
|
});
|
|
|
|
it('replays the canonical failure rather than an upload error', () => {
|
|
expect(
|
|
ackFromDurableExecution('command-1', {
|
|
event_type: 'execution.failed',
|
|
payload: { error_code: 'TOOL_FAILED', error: 'original failure' },
|
|
})
|
|
).toMatchObject({
|
|
status: 'failed',
|
|
error_code: 'TOOL_FAILED',
|
|
error: 'original failure',
|
|
});
|
|
});
|
|
|
|
it('preserves a queued execution result when restart reconciliation races it', () => {
|
|
const command = {
|
|
id: 'command-1',
|
|
session_id: 'session-1',
|
|
user_id: 1,
|
|
source_channel: 'remote_control' as const,
|
|
type: 'user_message',
|
|
target_project_id: 'project-1',
|
|
payload: {},
|
|
};
|
|
const completed = {
|
|
status: 'completed' as const,
|
|
event_id: 'command-1:execution-result',
|
|
result: { run_id: 'run-1' },
|
|
};
|
|
|
|
__remoteControlBridgeTestHooks.queuePendingCommandResult({
|
|
command,
|
|
body: completed,
|
|
});
|
|
const durable = __remoteControlBridgeTestHooks.queuePendingCommandResult({
|
|
command,
|
|
body: {
|
|
status: 'failed',
|
|
event_id: 'command-1:recovery-outcome-unknown',
|
|
result: {},
|
|
error_code: 'COMMAND_OUTCOME_UNKNOWN_AFTER_RESTART',
|
|
},
|
|
});
|
|
|
|
expect(durable.body).toEqual(completed);
|
|
expect(
|
|
__remoteControlBridgeTestHooks.ackFromPendingCommandResult(
|
|
command.id,
|
|
durable.body
|
|
)
|
|
).toMatchObject({
|
|
status: 'acknowledged',
|
|
result: { run_id: 'run-1' },
|
|
});
|
|
});
|
|
});
|