feat: add Makelore Robot hardware module

This commit is contained in:
2026-08-13 23:17:22 +08:00
parent 22add3f01f
commit aba5cae286
25 changed files with 3169 additions and 72 deletions

View File

@@ -0,0 +1,270 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AiHardwareApiError,
bindAiHardwareDevice,
createAiHardwareAgent,
getAiHardwareAgentConfiguration,
getAiHardwareAssignment,
getAiHardwareOverview,
updateAiHardwareAgentConfiguration,
updateAiHardwareAssignment,
createAiHardwareOperationId,
recoverAiHardwareCredential,
} from '@/lib/ai-hardware';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
const agent = { id: 'agent-1', name: 'Tutor', config_revision: 0 };
const device = { id: 'device-1', agent_id: 'agent-1', assignment_revision: 0 };
const configuration = {
...agent,
system_prompt: null,
lang_code: null,
language: null,
asr_model_id: null,
vad_model_id: null,
llm_model_id: null,
slm_model_id: null,
vllm_model_id: null,
tts_model_id: null,
tts_voice_id: null,
tts_language: null,
tts_volume: null,
tts_rate: null,
tts_pitch: null,
mem_model_id: null,
intent_model_id: null,
chat_history_conf: null,
};
describe('AI hardware renderer API', () => {
beforeEach(() => hostApiFetchMock.mockReset());
it('strictly reads overview and local public DTOs', async () => {
hostApiFetchMock.mockResolvedValue({
success: true,
data: { status: 'active', agents: [agent], devices: [device] },
});
await expect(getAiHardwareOverview()).resolves.toEqual({
status: 'active', agents: [agent], devices: [device],
});
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/ai-hardware', undefined);
});
it('sends create and bind business bodies without sensitive headers', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: agent })
.mockResolvedValueOnce({ success: true, data: device });
await createAiHardwareAgent('Tutor', { operationId: '11111111-1111-4111-8111-111111111111' });
await bindAiHardwareDevice('123456', 'agent-1', { operationId: '22222222-2222-4222-8222-222222222222' });
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/works/ai-hardware/agents', {
method: 'POST', body: JSON.stringify({ agent_name: 'Tutor', client_operation_id: '11111111-1111-4111-8111-111111111111' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/works/ai-hardware/device-bindings', {
method: 'POST', body: JSON.stringify({ activation_code: '123456', agent_id: 'agent-1', client_operation_id: '22222222-2222-4222-8222-222222222222' }),
});
for (const [, init] of hostApiFetchMock.mock.calls) {
expect(init).not.toHaveProperty('headers');
expect(JSON.stringify(init)).not.toMatch(/authorization|idempotency|if-match|token/i);
}
});
it('returns a reusable operation id on retryable mutation errors', async () => {
hostApiFetchMock.mockResolvedValue({
success: false, status: 504, code: 'AI_HARDWARE_TIMEOUT', error: 'hidden', retryable: true,
operation_id: '33333333-3333-4333-8333-333333333333',
});
const error = await createAiHardwareAgent('Tutor', {
operationId: '33333333-3333-4333-8333-333333333333',
}).catch((value: unknown) => value);
expect(error).toMatchObject({ operationId: '33333333-3333-4333-8333-333333333333', retryable: true });
expect(createAiHardwareOperationId()).toMatch(/^[0-9a-f-]{36}$/i);
});
it('wraps pre-envelope transport failures with the reusable operation id', async () => {
hostApiFetchMock.mockResolvedValue(undefined);
const operationId = '77777777-7777-4777-8777-777777777777';
let error: AiHardwareApiError | undefined;
try {
await updateAiHardwareAssignment('device-1', 0, 'agent-2', { operationId });
} catch (value) {
error = value as AiHardwareApiError;
}
expect(error).toBeDefined();
expect({
name: error.name,
status: error.status,
code: error.code,
retryable: error.retryable,
operationId: error.operationId,
message: error.message,
}).toEqual({
name: 'AiHardwareApiError', status: 502, code: 'AI_HARDWARE_INVALID_RESPONSE',
retryable: false, operationId, message: 'AI hardware service returned an invalid response',
});
expect(error.message).toBe('AI hardware service returned an invalid response');
expect(hostApiFetchMock.mock.calls[0][0]).toBe(
'/api/works/ai-hardware/devices/device-1/agent-assignment',
);
expect((hostApiFetchMock.mock.calls[0][1] as RequestInit).body).toContain(
`"client_operation_id":"${operationId}"`,
);
});
it('requests credential recovery with a reusable operation identity', async () => {
hostApiFetchMock.mockResolvedValue({ success: true, data: {
status: 'active', agents: [agent], devices: [device],
} });
const operationId = '88888888-8888-4888-8888-888888888888';
await expect(recoverAiHardwareCredential({ operationId })).resolves.toEqual({
status: 'active', agents: [agent], devices: [device],
});
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/ai-hardware/credential-recovery',
{ method: 'POST', body: JSON.stringify({ client_operation_id: operationId }) },
);
});
it('reads the stable credential recovery unavailable error', async () => {
hostApiFetchMock.mockResolvedValue({
success: false,
status: 409,
code: 'ai_hardware_credential_recovery_unavailable',
error: 'ignored Main text',
retryable: false,
operation_id: '99999999-9999-4999-8999-999999999999',
});
const error = await recoverAiHardwareCredential({
operationId: '99999999-9999-4999-8999-999999999999',
}).catch((value: unknown) => value);
expect(error).toMatchObject({
code: 'ai_hardware_credential_recovery_unavailable',
message: 'AI hardware credential recovery is not currently available',
operationId: '99999999-9999-4999-8999-999999999999',
});
});
it('returns Main-owned numeric revisions from versioned reads', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: configuration, revision: 0 })
.mockResolvedValueOnce({ success: true, data: device, revision: 0 });
await expect(getAiHardwareAgentConfiguration('agent/1')).resolves.toEqual({
data: configuration, revision: 0,
});
await expect(getAiHardwareAssignment('device/1')).resolves.toEqual({ data: device, revision: 0 });
expect(hostApiFetchMock.mock.calls[0][0]).toBe('/api/works/ai-hardware/agents/agent%2F1');
expect(hostApiFetchMock.mock.calls[1][0]).toBe(
'/api/works/ai-hardware/devices/device%2F1/agent-assignment',
);
});
it('passes revision as business JSON while Main owns concurrency headers', async () => {
hostApiFetchMock
.mockResolvedValueOnce({ success: true, data: configuration, revision: 1 })
.mockResolvedValueOnce({
success: true,
data: { ...device, agent_id: 'agent-2', assignment_revision: 1 },
revision: 1,
});
await updateAiHardwareAgentConfiguration('agent-1', 0, {
agent_name: 'New Tutor', clear_fields: ['system_prompt'],
}, { operationId: '44444444-4444-4444-8444-444444444444' });
await updateAiHardwareAssignment('device-1', 0, 'agent-2', { operationId: '55555555-5555-4555-8555-555555555555' });
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/works/ai-hardware/agents/agent-1', {
method: 'PATCH',
body: JSON.stringify({ revision: 0, agent_name: 'New Tutor', clear_fields: ['system_prompt'], client_operation_id: '44444444-4444-4444-8444-444444444444' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
2,
'/api/works/ai-hardware/devices/device-1/agent-assignment',
{ method: 'PUT', body: JSON.stringify({ revision: 0, agent_id: 'agent-2', client_operation_id: '55555555-5555-4555-8555-555555555555' }) },
);
});
it('rejects malformed success payloads instead of exposing unknown fields', async () => {
hostApiFetchMock.mockResolvedValue({
success: true,
data: { ...agent, token: 'must-not-pass' },
});
await expect(createAiHardwareAgent('Tutor')).rejects.toMatchObject<Partial<AiHardwareApiError>>({
name: 'AiHardwareApiError',
status: 502,
code: 'AI_HARDWARE_INVALID_RESPONSE',
});
});
it('exposes only stable sanitized Main error fields', async () => {
hostApiFetchMock.mockResolvedValue({
success: false,
status: 409,
code: 'ai_hardware_revision_conflict',
error: 'AI hardware state changed; refresh and retry',
retryable: true,
retry_after_seconds: 2,
});
await expect(getAiHardwareOverview()).rejects.toMatchObject<Partial<AiHardwareApiError>>({
name: 'AiHardwareApiError',
status: 409,
code: 'ai_hardware_revision_conflict',
retryable: true,
retryAfterSeconds: 2,
message: 'AI hardware state changed; refresh and retry',
});
});
it('does not leak unknown raw error detail', async () => {
hostApiFetchMock.mockResolvedValue({
success: false,
status: 'bad',
code: '../unsafe',
error: { token: 'private-token' },
detail: 'upstream internals',
});
const error = await getAiHardwareOverview().catch((value: unknown) => value);
expect(error).toMatchObject({
status: 502,
code: 'AI_HARDWARE_INVALID_RESPONSE',
});
expect(JSON.stringify(error)).not.toContain('private-token');
expect(String(error)).not.toContain('upstream internals');
});
it('rejects null updates and invalid clear fields before making a request', async () => {
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
system_prompt: null,
} as never)).rejects.toThrow('clear_fields');
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
clear_fields: ['agent_name'],
} as never)).rejects.toThrow('Invalid clear_fields');
expect(hostApiFetchMock).not.toHaveBeenCalled();
});
it('validates create, bind, and numeric update constraints before Main', async () => {
await expect(createAiHardwareAgent(' ')).rejects.toThrow('agent_name');
await expect(bindAiHardwareDevice('', 'agent-1')).rejects.toThrow('activation_code');
await expect(bindAiHardwareDevice('123456', '')).rejects.toThrow('agent_id');
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
tts_rate: 101,
})).rejects.toThrow('tts_rate');
await expect(updateAiHardwareAgentConfiguration('agent-1', 0, {
chat_history_conf: 3,
})).rejects.toThrow('chat_history_conf');
expect(hostApiFetchMock).not.toHaveBeenCalled();
});
});