eigent/test/unit/hooks/useRemoteControlBridge.followup.test.ts

269 lines
8.3 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, fetchPost } from '@/api/http';
import { __remoteControlBridgeTestHooks } from '@/hooks/useRemoteControlBridge';
import {
createFollowUpRequest,
listPendingFollowUpRequests,
markFollowUpRequestAdmitted,
} from '@/service/followUpQueueApi';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const restoreQueuedMessage = vi.fn();
const removeQueuedMessage = vi.fn();
const startTask = vi.fn();
vi.mock('@/api/http', () => ({
fetchGet: vi.fn(),
fetchPost: vi.fn(),
getBaseURL: vi.fn(() => 'http://localhost:5001'),
getLocalControlCapability: vi.fn(() => 'capability'),
proxyFetchGet: vi.fn(),
}));
vi.mock('@/service/followUpQueueApi', () => ({
createFollowUpRequest: vi.fn(),
getRemoteFollowUpByCommandId: vi.fn(),
listPendingFollowUpRequests: vi.fn(),
listPendingRemoteFollowUpRequests: vi.fn(),
markFollowUpRequestAdmitted: vi.fn(),
terminalContinuationAdmissionRejection: (error: any) => {
const detail = error?.response?.data?.detail;
return detail?.interaction_type === 'continuation_clarification' &&
detail?.code === 'continuation_clarification_required'
? detail
: null;
},
}));
vi.mock('@/store/projectStore', () => ({
useProjectStore: {
getState: () => ({
projects: { 'project-1': { chatStores: {} } },
getProjectById: () => ({ mode: 'single-agent' }),
getChatStore: () => ({ getState: () => ({ startTask }) }),
restoreQueuedMessage,
removeQueuedMessage,
}),
},
}));
vi.mock('@/store/spaceStore', () => ({
useSpaceStore: {
getState: () => ({
getProjectMeta: vi.fn(),
upsertSpaces: vi.fn(),
upsertProjectMetas: vi.fn(),
resetForUser: vi.fn(),
ensureLegacySpace: vi.fn(),
}),
},
projectMetaFromServer: vi.fn(),
}));
vi.mock('@/store/authStore', () => ({
getAuthStore: () => ({ logout: vi.fn() }),
}));
describe('Remote Control durable follow-up admission', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createFollowUpRequest).mockResolvedValue({
request_id: 'run-2',
project_id: 'project-1',
content: 'continue the report',
attachment_paths: [],
delivery_mode: 'wait',
status: 'pending',
source: 'remote_control',
source_command_id: 'command-1',
created_at: 1,
updated_at: 1,
});
vi.mocked(listPendingFollowUpRequests).mockResolvedValue([
{
request_id: 'run-2',
project_id: 'project-1',
content: 'continue the report',
attachment_paths: [],
delivery_mode: 'wait',
status: 'pending',
source: 'remote_control',
source_command_id: 'command-1',
created_at: 1,
updated_at: 1,
},
]);
vi.mocked(fetchGet).mockResolvedValue({
has_lock: true,
status: 'processing',
});
vi.mocked(markFollowUpRequestAdmitted).mockResolvedValue({} as never);
startTask.mockResolvedValue(undefined);
});
it('persists an active-Run phone message instead of calling /chat directly', async () => {
const schedule = vi.fn();
const command = {
id: 'command-1',
session_id: 'session-1',
user_id: 1,
source_channel: 'remote_control',
type: 'user_message',
target_project_id: 'project-1',
next_task_id: 'run-2',
payload: { content: 'continue the report' },
};
const ack = await __remoteControlBridgeTestHooks.executeRemoteCommand(
command,
'token',
schedule
);
expect(createFollowUpRequest).toHaveBeenCalledWith({
projectId: 'project-1',
requestId: 'run-2',
content: 'continue the report',
attachmentPaths: [],
source: 'remote_control',
sourceCommandId: 'command-1',
});
expect(schedule).toHaveBeenCalledWith(command);
expect(fetchPost).not.toHaveBeenCalledWith(
'/chat/project-1',
expect.anything()
);
expect(ack).toMatchObject({
status: 'acknowledged',
result: { follow_up_request_id: 'run-2', queued: true },
});
});
it('reconstructs an enqueue acknowledgement from its durable queue row', () => {
expect(
__remoteControlBridgeTestHooks.ackFromDurableFollowUp('command-1', {
request_id: 'run-2',
project_id: 'project-1',
content: 'continue the report',
attachment_paths: [],
delivery_mode: 'wait',
status: 'admitted',
admitted_run_id: 'run-2',
source: 'remote_control',
source_command_id: 'command-1',
created_at: 1,
updated_at: 2,
})
).toMatchObject({
status: 'acknowledged',
replayed_from_cache: true,
result: {
follow_up_request_id: 'run-2',
queued: false,
follow_up_status: 'admitted',
admitted_run_id: 'run-2',
},
});
});
it('marks an idle Project request admitted only after Brain accepts its stream', async () => {
vi.mocked(fetchGet).mockResolvedValue({ has_lock: false, status: 'idle' });
const command = {
id: 'command-1',
session_id: 'session-1',
user_id: 1,
source_channel: 'remote_control',
type: 'user_message',
target_project_id: 'project-1',
next_task_id: 'run-2',
payload: { content: 'continue the report' },
};
await __remoteControlBridgeTestHooks.executeRemoteCommand(command, 'token');
expect(startTask).toHaveBeenCalledWith(
'run-2',
undefined,
undefined,
undefined,
'continue the report',
[],
undefined,
'project-1',
'single-agent',
expect.objectContaining({ preserveTaskId: true, awaitAdmission: true })
);
expect(markFollowUpRequestAdmitted).toHaveBeenCalledWith(
'project-1',
'run-2',
'run-2'
);
expect(removeQueuedMessage).toHaveBeenCalledWith('project-1', 'run-2');
});
it('keeps the durable row pending when idle admission is rejected', async () => {
vi.mocked(fetchGet).mockResolvedValue({ has_lock: false, status: 'idle' });
startTask.mockRejectedValueOnce(new Error('admission rejected'));
const command = {
id: 'command-1',
session_id: 'session-1',
user_id: 1,
source_channel: 'remote_control',
type: 'user_message',
target_project_id: 'project-1',
next_task_id: 'run-2',
payload: { content: 'continue the report' },
};
await expect(
__remoteControlBridgeTestHooks.executeRemoteCommand(command, 'token')
).rejects.toThrow('admission rejected');
expect(markFollowUpRequestAdmitted).not.toHaveBeenCalled();
expect(removeQueuedMessage).not.toHaveBeenCalled();
});
it('removes the renderer row after a durable continuation clarification', async () => {
vi.mocked(fetchGet).mockResolvedValue({ has_lock: false, status: 'idle' });
const error: any = new Error('Say what should continue');
error.response = {
data: {
detail: {
code: 'continuation_clarification_required',
message: 'Say what should continue',
interaction_type: 'continuation_clarification',
project_state_version: 2,
},
},
};
startTask.mockRejectedValueOnce(error);
const command = {
id: 'command-1',
session_id: 'session-1',
user_id: 1,
source_channel: 'remote_control',
type: 'user_message',
target_project_id: 'project-1',
next_task_id: 'run-2',
payload: { content: 'continue the report' },
};
await expect(
__remoteControlBridgeTestHooks.executeRemoteCommand(command, 'token')
).rejects.toThrow('Say what should continue');
expect(markFollowUpRequestAdmitted).not.toHaveBeenCalled();
expect(removeQueuedMessage).toHaveBeenCalledWith('project-1', 'run-2');
});
});