Files
iofficeai--aionui/tests/unit/renderer/useGuidAgentSelection.dom.test.ts
2026-07-13 13:20:22 +08:00

729 lines
22 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Assistant } from '@/common/types/agent/assistantTypes';
import type { ManagedAgent } from '@/renderer/utils/model/agentTypes';
import {
buildAgentRuntimeModeState,
buildAgentRuntimeModelInfo,
buildAgentRuntimeSlashCommands,
buildAssistantModelInfo,
resolveInitialAssistantModel,
useGuidAssistantSelection,
} from '@/renderer/pages/guid/hooks/useGuidAssistantSelection';
let mockAssistants: Assistant[] = [];
let mockManagedAgents: ManagedAgent[] = [];
const { configGetMock, configSetMock } = vi.hoisted(() => ({
configGetMock: vi.fn(),
configSetMock: vi.fn(),
}));
vi.mock('@/common/config/configService', () => ({
configService: {
get: configGetMock,
set: configSetMock,
},
}));
vi.mock('@/renderer/pages/guid/hooks/useCustomAgentsLoader', () => ({
useCustomAgentsLoader: () => ({
assistants: mockAssistants,
}),
}));
vi.mock('@/renderer/hooks/agent/useManagedAgents', () => ({
useManagedAgentRuntimeCatalog: () => mockManagedAgents,
}));
describe('useGuidAssistantSelection', () => {
beforeEach(() => {
configGetMock.mockReturnValue(undefined);
configSetMock.mockResolvedValue(undefined);
mockManagedAgents = [];
mockAssistants = [
{
id: 'assistant-claude',
source: 'builtin',
name: 'Claude Assistant',
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: 1,
agent_id: 'agent-claude',
agent: { type: 'acp', source: 'builtin', acp_backend: 'claude' },
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: ['claude-opus', 'claude-sonnet'],
agent_status: 'online',
team_selectable: true,
deletable: false,
} satisfies Assistant,
];
});
it('derives availability and model info from assistant catalog data', async () => {
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('assistant-claude');
});
expect(result.current.selectedAssistantAvailable).toBe(true);
expect(result.current.selectedAcpModel).toBe('claude-opus');
expect(result.current.currentAcpCachedModelInfo).toEqual({
current_model_id: 'claude-opus',
current_model_label: 'claude-opus',
available_models: [
{ id: 'claude-opus', label: 'claude-opus' },
{ id: 'claude-sonnet', label: 'claude-sonnet' },
],
});
});
it('restores the last selected guid assistant before falling back to the aionrs default', async () => {
mockAssistants = [
assistantFixture({ id: 'bare-aionrs', runtimeKey: 'aionrs', source: 'generated', sortOrder: 1 }),
assistantFixture({ id: 'assistant-claude', runtimeKey: 'claude', source: 'builtin', sortOrder: 2 }),
];
configGetMock.mockImplementation((key: string) =>
key === 'guid.lastAssistantId' ? 'assistant-claude' : undefined
);
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('assistant-claude');
});
});
it('restores the last selected guid assistant when the guid page resets for a new chat', async () => {
mockAssistants = [
assistantFixture({ id: 'bare-aionrs', runtimeKey: 'aionrs', source: 'generated', sortOrder: 1 }),
assistantFixture({ id: 'assistant-claude', runtimeKey: 'claude', source: 'builtin', sortOrder: 2 }),
];
configGetMock.mockImplementation((key: string) =>
key === 'guid.lastAssistantId' ? 'assistant-claude' : undefined
);
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: true,
locationKey: 'new-chat',
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('assistant-claude');
});
});
it('persists manual guid assistant selections for the next visit', async () => {
mockAssistants = [
assistantFixture({ id: 'bare-aionrs', runtimeKey: 'aionrs', source: 'generated', sortOrder: 1 }),
assistantFixture({ id: 'assistant-claude', runtimeKey: 'claude', source: 'builtin', sortOrder: 2 }),
];
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('bare-aionrs');
});
act(() => {
result.current.setSelectedAssistantId('assistant-claude');
});
expect(configSetMock).toHaveBeenCalledWith('guid.lastAssistantId', 'assistant-claude');
});
it('falls back to the default assistant when the persisted guid assistant no longer exists', async () => {
mockAssistants = [
assistantFixture({ id: 'bare-aionrs', runtimeKey: 'aionrs', source: 'generated', sortOrder: 1 }),
assistantFixture({ id: 'assistant-claude', runtimeKey: 'claude', source: 'builtin', sortOrder: 2 }),
];
configGetMock.mockImplementation((key: string) =>
key === 'guid.lastAssistantId' ? 'removed-assistant' : undefined
);
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('bare-aionrs');
});
});
it('does not synthesize a backend slug when no assistants exist', async () => {
vi.resetModules();
vi.doMock('@/renderer/pages/guid/hooks/useCustomAgentsLoader', () => ({
useCustomAgentsLoader: () => ({
assistants: [],
}),
}));
const { useGuidAssistantSelection: useSelectionWithoutAssistants } =
await import('@/renderer/pages/guid/hooks/useGuidAssistantSelection');
const { result } = renderHook(() =>
useSelectionWithoutAssistants({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBeNull();
});
expect(result.current.defaultAssistantId).toBeNull();
expect(result.current.selectedAssistantBackend).toBe('');
expect(result.current.selectedAssistantAvailable).toBe(false);
vi.doUnmock('@/renderer/pages/guid/hooks/useCustomAgentsLoader');
vi.resetModules();
});
it('uses the selected assistant agent_id to read runtime catalogs from managed agents', async () => {
mockAssistants = [
{
id: 'custom-1781258588874-26ad',
source: 'user',
name: '文件规划助手 (Copy)',
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: 1,
agent_id: '2d23ff1c',
agent: {
type: 'acp',
source: 'builtin',
acp_backend: 'claude',
},
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: [],
agent_status: 'online',
team_selectable: true,
deletable: true,
} satisfies Assistant,
];
mockManagedAgents = [
{
id: '2d23ff1c',
backend: 'claude',
available_models: {
current_model_id: 'global.anthropic.claude-opus-4-8',
current_model_label: 'Opus 4.8',
available_models: [
{ id: 'default', label: 'Default' },
{ id: 'global.anthropic.claude-opus-4-8', label: 'Opus 4.8' },
],
},
available_modes: {
current_mode_id: 'bypassPermissions',
available_modes: [
{ id: 'default', name: 'Default' },
{ id: 'bypassPermissions', name: 'Bypass Permissions' },
],
},
} as unknown as ManagedAgent,
];
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('custom-1781258588874-26ad');
});
expect(result.current.selectedAcpModel).toBe('global.anthropic.claude-opus-4-8');
expect(result.current.currentAcpCachedModelInfo).toEqual({
current_model_id: 'global.anthropic.claude-opus-4-8',
current_model_label: 'Opus 4.8',
available_models: [
{ id: 'default', label: 'Default' },
{ id: 'global.anthropic.claude-opus-4-8', label: 'Opus 4.8' },
],
});
expect(result.current.selectedMode).toBe('bypassPermissions');
expect(result.current.currentAgentModeOptions).toEqual([
{ value: 'default', label: 'Default', description: undefined },
{ value: 'bypassPermissions', label: 'Bypass Permissions', description: undefined },
]);
});
it('keeps the full runtime model list when assistant models only contain a default', async () => {
mockAssistants = [
{
id: 'assistant-with-default-model',
source: 'user',
name: 'Assistant With Default Model',
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: 1,
agent_id: 'agent-claude',
agent: {
type: 'acp',
source: 'builtin',
acp_backend: 'claude',
},
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: ['default'],
agent_status: 'online',
team_selectable: true,
deletable: true,
} satisfies Assistant,
];
mockManagedAgents = [
{
id: 'agent-claude',
backend: 'claude',
available_models: {
current_model_id: 'default',
current_model_label: 'Default',
available_models: [
{ id: 'default', label: 'Default' },
{ id: 'claude-opus', label: 'Claude Opus' },
{ id: 'claude-sonnet', label: 'Claude Sonnet' },
],
},
} as unknown as ManagedAgent,
];
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('assistant-with-default-model');
});
expect(result.current.currentAcpCachedModelInfo?.available_models.map((model) => model.id)).toEqual([
'default',
'claude-opus',
'claude-sonnet',
]);
});
it('keeps a guid-page model selection in memory across same-assistant runtime catalog refreshes', async () => {
mockAssistants = [
{
id: 'assistant-with-runtime-models',
source: 'user',
name: 'Runtime Model Assistant',
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: 1,
agent_id: 'agent-claude',
agent: {
type: 'acp',
source: 'builtin',
acp_backend: 'claude',
},
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: [],
agent_status: 'online',
team_selectable: true,
deletable: true,
} satisfies Assistant,
];
const buildManagedAgent = () =>
({
id: 'agent-claude',
backend: 'claude',
available_models: {
current_model_id: 'default',
current_model_label: 'Default',
available_models: [
{ id: 'default', label: 'Default' },
{ id: 'global.anthropic.claude-opus-4-8', label: 'Opus 4.8' },
],
},
}) as unknown as ManagedAgent;
mockManagedAgents = [buildManagedAgent()];
const { result, rerender } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAcpModel).toBe('default');
});
act(() => {
result.current.setSelectedAcpModel('global.anthropic.claude-opus-4-8');
});
expect(result.current.selectedAcpModel).toBe('global.anthropic.claude-opus-4-8');
mockManagedAgents = [buildManagedAgent()];
rerender();
expect(result.current.selectedAcpModel).toBe('global.anthropic.claude-opus-4-8');
});
it('does not fall back to historical static modes when managed agent catalog has no modes', async () => {
mockAssistants = [
{
id: 'assistant-claude-empty',
source: 'generated',
name: 'Claude',
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: 1,
agent_id: 'agent-claude-empty',
agent: { type: 'acp', source: 'builtin', acp_backend: 'claude' },
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: [],
agent_status: 'online',
team_selectable: true,
deletable: false,
} satisfies Assistant,
];
mockManagedAgents = [{ id: 'agent-claude-empty', backend: 'claude' } as unknown as ManagedAgent];
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('assistant-claude-empty');
});
expect(result.current.currentAgentModeOptions).toEqual([]);
expect(result.current.selectedMode).toBe('default');
});
it('reads aionrs mode options from the managed agent catalog', async () => {
mockAssistants = [
{
id: 'bare:632f31d2',
source: 'generated',
name: 'Aion CLI',
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: 1,
agent_id: '632f31d2',
agent: { type: 'aionrs', source: 'internal' },
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: [],
agent_status: 'online',
team_selectable: true,
deletable: false,
} satisfies Assistant,
];
mockManagedAgents = [
{
id: '632f31d2',
agent_type: 'aionrs',
available_modes: {
current_mode_id: 'default',
available_modes: [
{ id: 'default', name: 'Default' },
{ id: 'auto_edit', name: 'Auto Edit' },
{ id: 'yolo', name: 'YOLO' },
],
},
} as unknown as ManagedAgent,
];
const { result } = renderHook(() =>
useGuidAssistantSelection({
resetAssistant: false,
})
);
await waitFor(() => {
expect(result.current.selectedAssistantId).toBe('bare:632f31d2');
});
expect(result.current.selectedAssistantBackend).toBe('aionrs');
expect(result.current.selectedMode).toBe('default');
expect(result.current.currentAgentModeOptions.map((mode) => mode.value)).toEqual(['default', 'auto_edit', 'yolo']);
});
});
function assistantFixture({
id,
runtimeKey,
source,
sortOrder,
}: {
id: string;
runtimeKey: string;
source: Assistant['source'];
sortOrder: number;
}): Assistant {
const isAionrs = runtimeKey === 'aionrs';
return {
id,
source,
name: id,
name_i18n: {},
description_i18n: {},
enabled: true,
sort_order: sortOrder,
agent_id: `agent-${runtimeKey}`,
agent: isAionrs
? { type: 'aionrs', source: 'internal' }
: { type: 'acp', source: 'builtin', acp_backend: runtimeKey },
enabled_skills: [],
custom_skill_names: [],
disabled_builtin_skills: [],
context_i18n: {},
prompts: [],
prompts_i18n: {},
models: [],
agent_status: 'online',
team_selectable: true,
deletable: source === 'user',
};
}
describe('assistant model helpers', () => {
it('builds model and mode info from agent runtime payloads', () => {
const agent = {
available_models: JSON.stringify({
current_model_id: 'global.anthropic.claude-opus-4-8',
current_model_label: 'Opus 4.8',
available_models: [
{ id: 'default', label: 'Default', description: 'Sonnet 4.6 · Best for everyday tasks' },
{
id: 'global.anthropic.claude-opus-4-8',
label: 'Opus 4.8',
description: 'Most capable for complex work',
},
],
}),
available_modes: JSON.stringify({
current_mode_id: 'bypassPermissions',
available_modes: [
{ id: 'default', name: 'Default' },
{ id: 'bypassPermissions', name: 'Bypass Permissions' },
],
}),
};
expect(buildAgentRuntimeModelInfo(agent)).toEqual({
current_model_id: 'global.anthropic.claude-opus-4-8',
current_model_label: 'Opus 4.8',
available_models: [
{ id: 'default', label: 'Default', description: 'Sonnet 4.6 · Best for everyday tasks' },
{
id: 'global.anthropic.claude-opus-4-8',
label: 'Opus 4.8',
description: 'Most capable for complex work',
},
],
});
expect(buildAgentRuntimeModeState(agent)).toEqual({
currentMode: 'bypassPermissions',
options: [
{ value: 'default', label: 'Default', description: undefined },
{ value: 'bypassPermissions', label: 'Bypass Permissions', description: undefined },
],
});
});
it('prefers model config_options before falling back to available_models', () => {
const agent = {
config_options: {
config_options: [
{
id: 'model',
category: 'model',
type: 'select',
currentValue: 'gpt-5.5',
options: [
{ value: 'gpt-5.5', name: 'GPT-5.5' },
{ value: 'gpt-5.2', name: 'gpt-5.2' },
],
},
],
},
available_models: {
current_model_id: 'legacy-model',
available_models: [{ id: 'legacy-model', label: 'Legacy Model' }],
},
};
expect(buildAgentRuntimeModelInfo(agent)).toEqual({
current_model_id: 'gpt-5.5',
current_model_label: 'GPT-5.5',
available_models: [
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.2', label: 'gpt-5.2' },
],
});
});
it('prefers mode config_options before falling back to available_modes', () => {
const agent = {
config_options: {
config_options: [
{
id: 'mode',
category: 'mode',
type: 'select',
currentValue: 'full-access',
options: [
{ value: 'read-only', name: 'Read Only' },
{ value: 'full-access', name: 'Full Access' },
],
},
],
},
available_modes: {
current_mode_id: 'legacy-mode',
available_modes: [{ id: 'legacy-mode', name: 'Legacy Mode' }],
},
};
expect(buildAgentRuntimeModeState(agent)).toEqual({
currentMode: 'full-access',
options: [
{ value: 'read-only', label: 'Read Only', description: undefined },
{ value: 'full-access', label: 'Full Access', description: undefined },
],
});
});
it('builds slash commands from persisted agent available_commands metadata', () => {
const agent = {
available_commands: JSON.stringify({
available_commands: [
{
name: 'review',
description: 'Review the current diff',
input: {
hint: '⌘R',
},
_meta: {
completion_behavior: 'neutral_tip_on_empty',
empty_turn_tip_code: 'acp.empty_turn.choose_command',
empty_turn_tip_params: { command_count: 1 },
},
},
],
}),
};
expect(buildAgentRuntimeSlashCommands(agent)).toEqual([
{
name: 'review',
description: 'Review the current diff',
hint: '⌘R',
kind: 'template',
source: 'acp',
selectionBehavior: 'insert',
completionBehavior: 'neutral_tip_on_empty',
emptyTurnTipCode: 'acp.empty_turn.choose_command',
emptyTurnTipParams: { command_count: 1 },
},
]);
});
it('ignores malformed available_commands metadata', () => {
expect(
buildAgentRuntimeSlashCommands({
available_commands: JSON.stringify({
available_commands: [{ name: 'missing-description' }, null, 'bad'],
}),
})
).toEqual([]);
});
it('builds ACP model info from assistant models', () => {
expect(buildAssistantModelInfo(['claude-opus', 'claude-sonnet'])).toEqual({
current_model_id: 'claude-opus',
current_model_label: 'claude-opus',
available_models: [
{ id: 'claude-opus', label: 'claude-opus' },
{ id: 'claude-sonnet', label: 'claude-sonnet' },
],
});
});
it('builds Codex ACP model info from assistant models', () => {
expect(buildAssistantModelInfo(['gpt-5.5', 'gpt-5.4'])).toEqual({
current_model_id: 'gpt-5.5',
current_model_label: 'gpt-5.5',
available_models: [
{ id: 'gpt-5.5', label: 'gpt-5.5' },
{ id: 'gpt-5.4', label: 'gpt-5.4' },
],
});
});
it('defaults to the first assistant model when no assistant preference has been applied yet', () => {
expect(resolveInitialAssistantModel(['claude-opus', 'claude-sonnet'])).toBe('claude-opus');
});
it('does not synthesize Codex models when the assistant catalog has none', () => {
expect(buildAssistantModelInfo([])).toBeNull();
expect(resolveInitialAssistantModel([])).toBeNull();
});
});