@steerable/agent-shell-web 0.6.18 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -8
- package/src/lib/chat-transport.ts +1 -2
- package/src/lib/electron-bridge.ts +2 -2
- package/src/lib/http-bridge.ts +18 -4
- package/vite.base.d.ts +7 -0
- package/vite.base.js +83 -0
- package/src/brand.test.ts +0 -22
- package/src/components/AgentSidebar.test.tsx +0 -791
- package/src/components/ChatHeader.test.tsx +0 -230
- package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
- package/src/components/chat/ApprovalModal.test.tsx +0 -83
- package/src/components/chat/AskUserModalHost.test.tsx +0 -176
- package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
- package/src/components/chat/AssistantMessage.test.tsx +0 -68
- package/src/components/chat/ChatInput.test.tsx +0 -609
- package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
- package/src/components/chat/Markdown.test.tsx +0 -170
- package/src/components/chat/ModelPicker.test.tsx +0 -149
- package/src/components/chat/SessionTreeModal.test.tsx +0 -161
- package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
- package/src/components/chat/TaskPanelModal.test.tsx +0 -198
- package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
- package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
- package/src/components/chat/elapsed.test.ts +0 -42
- package/src/components/chat/executed-actions-model.test.ts +0 -134
- package/src/components/chat/orchestration-children-model.test.ts +0 -115
- package/src/components/chat/turn-timeline.test.ts +0 -97
- package/src/components/chat/useChatTasks.test.ts +0 -136
- package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
- package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
- package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
- package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
- package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
- package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
- package/src/components/settings/compat-flags-model.test.ts +0 -71
- package/src/components/settings/llm-vendors.test.ts +0 -171
- package/src/components/settings/model-capabilities.test.ts +0 -88
- package/src/components/settings/preset-choice-model.test.ts +0 -96
- package/src/hooks/useChatsAndAgents.test.ts +0 -165
- package/src/lib/attachments.test.ts +0 -127
- package/src/lib/chat-title.test.ts +0 -53
- package/src/lib/chat-transport.test.ts +0 -277
- package/src/lib/date-groups.test.ts +0 -62
- package/src/lib/demo-flag.test.ts +0 -17
- package/src/lib/electron-bridge.test.ts +0 -54
- package/src/lib/exec-policy.test.ts +0 -71
- package/src/lib/http-bridge.test.ts +0 -246
- package/src/lib/insights.test.ts +0 -56
- package/src/lib/local-api.test.ts +0 -353
- package/src/lib/pending-first-message.test.ts +0 -17
- package/src/lib/slash-sources.test.ts +0 -161
- package/src/pages/AgentPage.test.tsx +0 -627
- package/src/pages/SettingsPage.test.tsx +0 -102
- package/vite.base.ts +0 -94
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { cleanup, render, screen } from '@testing-library/react';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import type { SidecarSandboxPosture } from '@/lib/local-api';
|
|
4
|
-
|
|
5
|
-
// 沙箱诚实告知的渲染层锁定:收容失败必须说出「无法收容、已拒绝启动」,
|
|
6
|
-
// 而不是谎称进程仍在无隔离运行。posture/API 那一层由
|
|
7
|
-
// tests/sidecar/supervisor-sandbox.test.ts 与 sandbox-posture.e2e.test.ts 覆盖;
|
|
8
|
-
// 这里只管「用户看不看得见」。
|
|
9
|
-
|
|
10
|
-
const getSidecarSandboxPosture = vi.fn();
|
|
11
|
-
|
|
12
|
-
vi.mock('@/lib/electron-bridge', () => ({ isElectron: () => true }));
|
|
13
|
-
vi.mock('@/lib/local-api', () => ({
|
|
14
|
-
getSidecarSandboxPosture: () => getSidecarSandboxPosture(),
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
|
-
const { SecuritySettingsPanel } = await import('./SecuritySettingsPanel');
|
|
18
|
-
|
|
19
|
-
function posture(over: Partial<SidecarSandboxPosture>): SidecarSandboxPosture {
|
|
20
|
-
return { backend: 'none', enforcement: 'none', reason: 'platform_unsupported', ...over };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
getSidecarSandboxPosture.mockReset();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
afterEach(cleanup);
|
|
28
|
-
|
|
29
|
-
describe('SecuritySettingsPanel — layer-1 态势披露', () => {
|
|
30
|
-
it.each([
|
|
31
|
-
['platform_unsupported', '当前平台没有可用的进程沙箱后端'],
|
|
32
|
-
['seatbelt_missing', '未找到 /usr/bin/sandbox-exec'],
|
|
33
|
-
['profile_failed', '沙箱配置(Seatbelt profile)生成失败'],
|
|
34
|
-
['wrap_failed', 'Linux 进程沙箱'],
|
|
35
|
-
['helper_missing', '未找到 win-spawn-helper.exe'],
|
|
36
|
-
] as const)('收容失败 %s:告知已拒绝启动,不谎称仍在跑', async (reason, detail) => {
|
|
37
|
-
getSidecarSandboxPosture.mockResolvedValue({ posture: posture({ reason }) });
|
|
38
|
-
render(<SecuritySettingsPanel />);
|
|
39
|
-
|
|
40
|
-
const headline = await screen.findByText(/无法收容、已拒绝启动 —/);
|
|
41
|
-
expect(headline.textContent).toContain(detail);
|
|
42
|
-
expect(screen.getByText(/没有启动/)).toBeTruthy();
|
|
43
|
-
expect(screen.queryByText(/未沙箱 —/)).toBeNull();
|
|
44
|
-
expect(screen.getByText(/STEERABLE_SIDECAR_SANDBOX=0/)).toBeTruthy();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('手动关闭中性展示,不冒充告警也不谎称已隔离', async () => {
|
|
48
|
-
getSidecarSandboxPosture.mockResolvedValue({
|
|
49
|
-
posture: posture({ reason: 'disabled_by_env' }),
|
|
50
|
-
});
|
|
51
|
-
render(<SecuritySettingsPanel />);
|
|
52
|
-
|
|
53
|
-
expect((await screen.findByText(/已手动关闭 —/)).textContent).toContain(
|
|
54
|
-
'STEERABLE_SIDECAR_SANDBOX=0',
|
|
55
|
-
);
|
|
56
|
-
expect(screen.queryByText(/无法收容、已拒绝启动 —/)).toBeNull();
|
|
57
|
-
expect(screen.getByText(/无操作系统隔离运行/)).toBeTruthy();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('Seatbelt 生效时记为「部分」,不谎称完全强制', async () => {
|
|
61
|
-
getSidecarSandboxPosture.mockResolvedValue({
|
|
62
|
-
posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
|
|
63
|
-
});
|
|
64
|
-
render(<SecuritySettingsPanel />);
|
|
65
|
-
|
|
66
|
-
expect(await screen.findByText(/Seatbelt · 部分强制/)).toBeTruthy();
|
|
67
|
-
expect(screen.getByText(/不识别主机名/)).toBeTruthy();
|
|
68
|
-
expect(screen.queryByText(/无法收容、已拒绝启动 —/)).toBeNull();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('bwrap 生效时记为部分强制', async () => {
|
|
72
|
-
getSidecarSandboxPosture.mockResolvedValue({
|
|
73
|
-
posture: posture({ backend: 'bwrap', enforcement: 'partial', reason: 'active' }),
|
|
74
|
-
});
|
|
75
|
-
render(<SecuritySettingsPanel />);
|
|
76
|
-
expect(await screen.findByText(/bwrap · 部分强制/)).toBeTruthy();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('sidecar 未就绪时不冒充任何一种姿态', async () => {
|
|
80
|
-
getSidecarSandboxPosture.mockRejectedValue(new Error('503'));
|
|
81
|
-
render(<SecuritySettingsPanel />);
|
|
82
|
-
|
|
83
|
-
expect(await screen.findByText(/sidecar 未就绪/)).toBeTruthy();
|
|
84
|
-
expect(screen.queryByText(/无法收容、已拒绝启动 —/)).toBeNull();
|
|
85
|
-
expect(screen.queryByText(/部分强制/)).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('出网退回端口级时披露原因(此前只有主进程日志可见)', async () => {
|
|
89
|
-
getSidecarSandboxPosture.mockResolvedValue({
|
|
90
|
-
posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
|
|
91
|
-
egress: {
|
|
92
|
-
mode: 'port-only-fallback',
|
|
93
|
-
reason: '检测到系统/环境代理(127.0.0.1:7890),按主机管控已退回端口级',
|
|
94
|
-
},
|
|
95
|
-
});
|
|
96
|
-
render(<SecuritySettingsPanel />);
|
|
97
|
-
|
|
98
|
-
expect(await screen.findByText(/已退回端口级/)).toBeTruthy();
|
|
99
|
-
expect(screen.getByText(/127\.0\.0\.1:7890/)).toBeTruthy();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('按主机代理生效时说明逐项放行语义', async () => {
|
|
103
|
-
getSidecarSandboxPosture.mockResolvedValue({
|
|
104
|
-
posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
|
|
105
|
-
egress: { mode: 'per-host-proxy', reason: null },
|
|
106
|
-
});
|
|
107
|
-
render(<SecuritySettingsPanel />);
|
|
108
|
-
|
|
109
|
-
expect(await screen.findByText(/按主机白名单代理生效中/)).toBeTruthy();
|
|
110
|
-
expect(screen.getByText(/逐项放行/)).toBeTruthy();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('无 egress 字段(旧后端)时不渲染出网行', async () => {
|
|
114
|
-
getSidecarSandboxPosture.mockResolvedValue({
|
|
115
|
-
posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
|
|
116
|
-
});
|
|
117
|
-
render(<SecuritySettingsPanel />);
|
|
118
|
-
|
|
119
|
-
await screen.findByText(/Seatbelt · 部分强制/);
|
|
120
|
-
expect(screen.queryByText(/出网管控/)).toBeNull();
|
|
121
|
-
});
|
|
122
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { cleanup, render, screen } from '@testing-library/react';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
|
|
4
|
-
const request = vi.fn();
|
|
5
|
-
|
|
6
|
-
vi.mock('@/lib/electron-bridge', () => ({
|
|
7
|
-
isElectron: () => true,
|
|
8
|
-
getElectronBridge: () => ({
|
|
9
|
-
localBackend: { request },
|
|
10
|
-
local: {},
|
|
11
|
-
}),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
const { SkillsSettingsPanel } = await import('./SkillsSettingsPanel');
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
request.mockReset();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(cleanup);
|
|
21
|
-
|
|
22
|
-
describe('SkillsSettingsPanel', () => {
|
|
23
|
-
it('shows a 工作区 badge and hides uninstall for workspace skills', async () => {
|
|
24
|
-
request.mockResolvedValue({
|
|
25
|
-
skills: [
|
|
26
|
-
{
|
|
27
|
-
name: 'compute-recursion',
|
|
28
|
-
displayName: '递归函数计算',
|
|
29
|
-
description: '算递推第 n 项',
|
|
30
|
-
origin: 'workspace',
|
|
31
|
-
isBuiltin: false,
|
|
32
|
-
layer: 'catalog',
|
|
33
|
-
modelInvocable: false,
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
});
|
|
37
|
-
render(<SkillsSettingsPanel />);
|
|
38
|
-
const row = await screen.findByTestId('skill-row-compute-recursion');
|
|
39
|
-
expect(row.textContent).toContain('工作区');
|
|
40
|
-
expect(row.textContent).not.toContain('内置');
|
|
41
|
-
expect(screen.queryByTestId('skill-uninstall-compute-recursion')).toBeNull();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('keeps uninstall on imported user skills', async () => {
|
|
45
|
-
request.mockResolvedValue({
|
|
46
|
-
skills: [
|
|
47
|
-
{
|
|
48
|
-
name: 'frontend-design',
|
|
49
|
-
displayName: '',
|
|
50
|
-
description: 'UI skill',
|
|
51
|
-
origin: 'user',
|
|
52
|
-
isBuiltin: false,
|
|
53
|
-
layer: 'catalog',
|
|
54
|
-
modelInvocable: true,
|
|
55
|
-
},
|
|
56
|
-
],
|
|
57
|
-
});
|
|
58
|
-
render(<SkillsSettingsPanel />);
|
|
59
|
-
await screen.findByTestId('skill-row-frontend-design');
|
|
60
|
-
expect(screen.getByTestId('skill-uninstall-frontend-design')).toBeTruthy();
|
|
61
|
-
});
|
|
62
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
|
|
4
|
-
const request = vi.fn();
|
|
5
|
-
|
|
6
|
-
vi.mock('@/lib/electron-bridge', () => ({
|
|
7
|
-
isElectron: () => true,
|
|
8
|
-
getElectronBridge: () => ({
|
|
9
|
-
localBackend: { request },
|
|
10
|
-
}),
|
|
11
|
-
}));
|
|
12
|
-
|
|
13
|
-
const { WebSearchSettingsPanel } = await import('./WebSearchSettingsPanel');
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
request.mockReset();
|
|
17
|
-
request.mockResolvedValue({ provider: 'tavily', apiKey: '' });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(cleanup);
|
|
21
|
-
|
|
22
|
-
describe('WebSearchSettingsPanel', () => {
|
|
23
|
-
it('loads Tavily by default and hides the key field on the free backend', async () => {
|
|
24
|
-
render(<WebSearchSettingsPanel />);
|
|
25
|
-
await screen.findByTestId('web-search-provider-ddg');
|
|
26
|
-
expect(screen.getByText('Tavily API Key')).toBeTruthy();
|
|
27
|
-
|
|
28
|
-
fireEvent.click(screen.getByTestId('web-search-provider-ddg'));
|
|
29
|
-
expect(screen.queryByText('Tavily API Key')).toBeNull();
|
|
30
|
-
expect(screen.getByText(/DuckDuckGo 公开搜索页/)).toBeTruthy();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('POSTs provider=ddg and shows the restart copy', async () => {
|
|
34
|
-
request
|
|
35
|
-
.mockResolvedValueOnce({ provider: 'tavily' })
|
|
36
|
-
.mockResolvedValueOnce({ provider: 'ddg' });
|
|
37
|
-
|
|
38
|
-
render(<WebSearchSettingsPanel />);
|
|
39
|
-
await screen.findByTestId('web-search-provider-ddg');
|
|
40
|
-
fireEvent.click(screen.getByTestId('web-search-provider-ddg'));
|
|
41
|
-
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
42
|
-
|
|
43
|
-
await waitFor(() => {
|
|
44
|
-
expect(request).toHaveBeenCalledWith(
|
|
45
|
-
expect.objectContaining({
|
|
46
|
-
method: 'POST',
|
|
47
|
-
path: '/api/v2/local-settings/web-search',
|
|
48
|
-
body: { provider: 'ddg', apiKey: '' },
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
expect(await screen.findByText(/重启应用后 sidecar 会注册免费搜索/)).toBeTruthy();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('keeps 免费 selected when the save echo omits provider', async () => {
|
|
56
|
-
request.mockResolvedValueOnce({ provider: 'tavily' }).mockResolvedValueOnce({});
|
|
57
|
-
|
|
58
|
-
render(<WebSearchSettingsPanel />);
|
|
59
|
-
await screen.findByTestId('web-search-provider-ddg');
|
|
60
|
-
fireEvent.click(screen.getByTestId('web-search-provider-ddg'));
|
|
61
|
-
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
62
|
-
|
|
63
|
-
expect(await screen.findByText(/重启应用后 sidecar 会注册免费搜索/)).toBeTruthy();
|
|
64
|
-
expect(screen.queryByText('Tavily API Key')).toBeNull();
|
|
65
|
-
expect(screen.getByTestId('web-search-provider-ddg').className).toContain('border-agent-foreground/40');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('POSTs a Tavily key and keeps the key field', async () => {
|
|
69
|
-
request
|
|
70
|
-
.mockResolvedValueOnce({ provider: 'tavily' })
|
|
71
|
-
.mockResolvedValueOnce({ provider: 'tavily', apiKey: 'tvly-k' });
|
|
72
|
-
|
|
73
|
-
render(<WebSearchSettingsPanel />);
|
|
74
|
-
const keyInput = await screen.findByPlaceholderText('tvly-...(留空 = 不注册 Tavily)');
|
|
75
|
-
fireEvent.change(keyInput, { target: { value: ' tvly-k ' } });
|
|
76
|
-
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
77
|
-
|
|
78
|
-
await waitFor(() => {
|
|
79
|
-
expect(request).toHaveBeenCalledWith(
|
|
80
|
-
expect.objectContaining({
|
|
81
|
-
method: 'POST',
|
|
82
|
-
path: '/api/v2/local-settings/web-search',
|
|
83
|
-
body: { provider: 'tavily', apiKey: 'tvly-k' },
|
|
84
|
-
}),
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
expect(await screen.findByText(/重启应用后 sidecar 会注册 web_search/)).toBeTruthy();
|
|
88
|
-
expect(screen.getByText('Tavily API Key')).toBeTruthy();
|
|
89
|
-
});
|
|
90
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
COMPAT_AUTO,
|
|
4
|
-
formStateFromOverrides,
|
|
5
|
-
overridesFromFormState,
|
|
6
|
-
} from './compat-flags-model';
|
|
7
|
-
import type { CompatFlagDescriptor } from '@/lib/local-api';
|
|
8
|
-
|
|
9
|
-
// 与框架 compat.py `_FLAG_WIRE_SPEC` 同形的描述符夹具。
|
|
10
|
-
const FLAGS: CompatFlagDescriptor[] = [
|
|
11
|
-
{ key: 'supportsUsageInStreaming', field: 'supports_usage_in_streaming', kind: 'bool', default: true, description: '' },
|
|
12
|
-
{ key: 'maxTokensField', field: 'max_tokens_field', kind: 'enum:max_tokens,max_completion_tokens', default: 'max_tokens', description: '' },
|
|
13
|
-
{ key: 'supportsTemperature', field: 'supports_temperature', kind: 'bool', default: true, description: '' },
|
|
14
|
-
{ key: 'reasoningDeltaFields', field: 'reasoning_delta_fields', kind: 'string-list', default: ['reasoning_content', 'reasoning'], description: '' },
|
|
15
|
-
];
|
|
16
|
-
|
|
17
|
-
describe('compat-flags-model (W1.3.2)', () => {
|
|
18
|
-
it('unset overrides render as auto for every flag', () => {
|
|
19
|
-
expect(formStateFromOverrides(undefined, FLAGS)).toEqual({
|
|
20
|
-
supportsUsageInStreaming: COMPAT_AUTO,
|
|
21
|
-
maxTokensField: COMPAT_AUTO,
|
|
22
|
-
supportsTemperature: COMPAT_AUTO,
|
|
23
|
-
reasoningDeltaFields: COMPAT_AUTO,
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('persisted overrides map to their form values', () => {
|
|
28
|
-
const state = formStateFromOverrides(
|
|
29
|
-
{ supportsTemperature: false, reasoningDeltaFields: ['reasoning_content'] },
|
|
30
|
-
FLAGS,
|
|
31
|
-
);
|
|
32
|
-
expect(state.supportsTemperature).toBe('false');
|
|
33
|
-
expect(state.reasoningDeltaFields).toBe('reasoning_content');
|
|
34
|
-
expect(state.maxTokensField).toBe(COMPAT_AUTO);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('all-auto form state produces no overrides (auto-detect fallback)', () => {
|
|
38
|
-
const state = formStateFromOverrides(undefined, FLAGS);
|
|
39
|
-
expect(overridesFromFormState(state, FLAGS)).toBeUndefined();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('form state builds a typed overrides payload', () => {
|
|
43
|
-
const overrides = overridesFromFormState(
|
|
44
|
-
{
|
|
45
|
-
supportsTemperature: 'false',
|
|
46
|
-
maxTokensField: 'max_completion_tokens',
|
|
47
|
-
reasoningDeltaFields: 'reasoning_content, reasoning',
|
|
48
|
-
supportsUsageInStreaming: COMPAT_AUTO,
|
|
49
|
-
},
|
|
50
|
-
FLAGS,
|
|
51
|
-
);
|
|
52
|
-
expect(overrides).toEqual({
|
|
53
|
-
supportsTemperature: false,
|
|
54
|
-
maxTokensField: 'max_completion_tokens',
|
|
55
|
-
reasoningDeltaFields: ['reasoning_content', 'reasoning'],
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('invalid enum values and empty lists are dropped', () => {
|
|
60
|
-
const overrides = overridesFromFormState(
|
|
61
|
-
{ maxTokensField: 'bogus_field', reasoningDeltaFields: ' ' },
|
|
62
|
-
FLAGS,
|
|
63
|
-
);
|
|
64
|
-
expect(overrides).toBeUndefined();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('round-trips: overrides → form → overrides', () => {
|
|
68
|
-
const original = { supportsTemperature: false as const, reasoningDeltaFields: ['reasoning'] };
|
|
69
|
-
expect(overridesFromFormState(formStateFromOverrides(original, FLAGS), FLAGS)).toEqual(original);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
defaultModelForVendor,
|
|
4
|
-
FALLBACK_VENDORS,
|
|
5
|
-
inferVendorId,
|
|
6
|
-
llmProviderFromWireKind,
|
|
7
|
-
mergeVendorOptions,
|
|
8
|
-
modelPickerOptions,
|
|
9
|
-
modelPickerRows,
|
|
10
|
-
vendorLabel,
|
|
11
|
-
type VendorOption,
|
|
12
|
-
} from './llm-vendors';
|
|
13
|
-
import type { LlmSettings } from '@/lib/local-api';
|
|
14
|
-
|
|
15
|
-
const vendors = mergeVendorOptions([
|
|
16
|
-
{
|
|
17
|
-
id: 'deepseek',
|
|
18
|
-
apiBaseUrl: 'https://api.deepseek.com',
|
|
19
|
-
envVars: [],
|
|
20
|
-
wireKind: 'openai_compat',
|
|
21
|
-
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
id: 'anthropic',
|
|
25
|
-
apiBaseUrl: 'https://api.anthropic.com',
|
|
26
|
-
envVars: [],
|
|
27
|
-
wireKind: 'anthropic',
|
|
28
|
-
models: ['claude-sonnet-4-6'],
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
id: 'azure',
|
|
32
|
-
apiBaseUrl: null,
|
|
33
|
-
envVars: ['AZURE_API_KEY'],
|
|
34
|
-
wireKind: 'openai_compat',
|
|
35
|
-
models: ['gpt-4o'],
|
|
36
|
-
},
|
|
37
|
-
]);
|
|
38
|
-
|
|
39
|
-
describe('mergeVendorOptions', () => {
|
|
40
|
-
it('pins featured vendors and injects ollama / custom', () => {
|
|
41
|
-
const ids = vendors.map((v) => v.id);
|
|
42
|
-
expect(ids.slice(0, 2)).toEqual(['deepseek', 'anthropic']);
|
|
43
|
-
expect(ids).toContain('ollama');
|
|
44
|
-
expect(ids).toContain('custom');
|
|
45
|
-
expect(ids).toContain('azure');
|
|
46
|
-
expect(vendorLabel('alibaba-cn')).toBe('阿里云百炼');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('orders the fallback featured list by common usage', () => {
|
|
50
|
-
expect(
|
|
51
|
-
mergeVendorOptions(FALLBACK_VENDORS)
|
|
52
|
-
.filter((v) => v.featured)
|
|
53
|
-
.map((v) => v.id),
|
|
54
|
-
).toEqual([
|
|
55
|
-
'deepseek',
|
|
56
|
-
'alibaba-cn',
|
|
57
|
-
'moonshotai-cn',
|
|
58
|
-
'zhipuai',
|
|
59
|
-
'openai',
|
|
60
|
-
'anthropic',
|
|
61
|
-
'google',
|
|
62
|
-
'openrouter',
|
|
63
|
-
'xai',
|
|
64
|
-
'groq',
|
|
65
|
-
'lmstudio',
|
|
66
|
-
'ollama',
|
|
67
|
-
'custom',
|
|
68
|
-
]);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('inferVendorId', () => {
|
|
73
|
-
it('prefers an explicit vendorId when that vendor is listed', () => {
|
|
74
|
-
expect(
|
|
75
|
-
inferVendorId({ provider: 'openai-compat', vendorId: 'anthropic', model: 'x' }, vendors),
|
|
76
|
-
).toBe('anthropic');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('matches a saved DeepSeek URL back to the catalog vendor', () => {
|
|
80
|
-
const settings: LlmSettings = {
|
|
81
|
-
provider: 'openai-compat',
|
|
82
|
-
model: 'deepseek-v4-flash',
|
|
83
|
-
baseUrl: 'https://api.deepseek.com',
|
|
84
|
-
};
|
|
85
|
-
expect(inferVendorId(settings, vendors)).toBe('deepseek');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('uses ollama for the local provider', () => {
|
|
89
|
-
expect(
|
|
90
|
-
inferVendorId(
|
|
91
|
-
{ provider: 'ollama', model: 'llama3.1:8b', baseUrl: 'http://127.0.0.1:11434' },
|
|
92
|
-
vendors,
|
|
93
|
-
),
|
|
94
|
-
).toBe('ollama');
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('llmProviderFromWireKind', () => {
|
|
99
|
-
it('maps sidecar kinds onto persisted provider ids', () => {
|
|
100
|
-
expect(llmProviderFromWireKind('openai_compat')).toBe('openai-compat');
|
|
101
|
-
expect(llmProviderFromWireKind('anthropic')).toBe('anthropic');
|
|
102
|
-
expect(llmProviderFromWireKind('google')).toBe('google');
|
|
103
|
-
expect(llmProviderFromWireKind('openai-responses')).toBe('openai-responses');
|
|
104
|
-
expect(llmProviderFromWireKind('ollama')).toBe('ollama');
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('defaultModelForVendor', () => {
|
|
109
|
-
it('keeps the current model when it is on the vendor list', () => {
|
|
110
|
-
const vendor = vendors.find((v) => v.id === 'deepseek') as VendorOption;
|
|
111
|
-
expect(defaultModelForVendor(vendor, 'deepseek-v4-pro')).toBe('deepseek-v4-pro');
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('falls back to the first catalog model', () => {
|
|
115
|
-
const vendor = vendors.find((v) => v.id === 'deepseek') as VendorOption;
|
|
116
|
-
expect(defaultModelForVendor(vendor, 'other')).toBe('deepseek-v4-flash');
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('modelPickerOptions', () => {
|
|
121
|
-
const catalog = ['deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'];
|
|
122
|
-
|
|
123
|
-
it('uses the live gateway list and does not mix in catalog ids', () => {
|
|
124
|
-
expect(
|
|
125
|
-
modelPickerOptions(['deepseek-chat', 'deepseek-reasoner'], catalog, 'deepseek-chat', 'live'),
|
|
126
|
-
).toEqual(['deepseek-chat', 'deepseek-reasoner']);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('keeps the currently selected id even if the gateway omitted it', () => {
|
|
130
|
-
expect(
|
|
131
|
-
modelPickerOptions(['deepseek-chat'], catalog, 'deepseek-v4-flash', 'live'),
|
|
132
|
-
).toEqual(['deepseek-chat', 'deepseek-v4-flash']);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('falls back to the vendor catalog when the gateway listing is unavailable', () => {
|
|
136
|
-
expect(modelPickerOptions([], catalog, 'deepseek-chat', 'offline')).toEqual(catalog);
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe('modelPickerRows', () => {
|
|
141
|
-
const catalog = ['deepseek-chat', 'deepseek-v4-flash'];
|
|
142
|
-
|
|
143
|
-
it('keeps live capability rows and marks a missing current id as unknown', () => {
|
|
144
|
-
const live = [
|
|
145
|
-
{
|
|
146
|
-
id: 'deepseek-v4-pro',
|
|
147
|
-
name: 'DeepSeek V4 Pro',
|
|
148
|
-
window: 1_000_000,
|
|
149
|
-
modalities: ['text'],
|
|
150
|
-
reasoningLevels: ['high', 'max'],
|
|
151
|
-
pricing: null,
|
|
152
|
-
joinedFrom: 'deepseek/deepseek-v4-pro',
|
|
153
|
-
capabilities: 'known' as const,
|
|
154
|
-
},
|
|
155
|
-
];
|
|
156
|
-
expect(modelPickerRows(live, catalog, 'deepseek-v4-flash', 'live')).toEqual([
|
|
157
|
-
{ id: 'deepseek-v4-pro', entry: live[0] },
|
|
158
|
-
{
|
|
159
|
-
id: 'deepseek-v4-flash',
|
|
160
|
-
entry: expect.objectContaining({ id: 'deepseek-v4-flash', capabilities: 'unknown' }),
|
|
161
|
-
},
|
|
162
|
-
]);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('does not invent capabilities for the vendor catalog fallback', () => {
|
|
166
|
-
expect(modelPickerRows([], catalog, 'deepseek-chat', 'offline')).toEqual([
|
|
167
|
-
{ id: 'deepseek-chat', entry: null },
|
|
168
|
-
{ id: 'deepseek-v4-flash', entry: null },
|
|
169
|
-
]);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { GatewayModelEntry } from '@/lib/local-api';
|
|
3
|
-
import {
|
|
4
|
-
formatContextWindow,
|
|
5
|
-
modelCapabilityChips,
|
|
6
|
-
} from './model-capabilities';
|
|
7
|
-
|
|
8
|
-
function entry(overrides: Partial<GatewayModelEntry> & Pick<GatewayModelEntry, 'id'>): GatewayModelEntry {
|
|
9
|
-
return {
|
|
10
|
-
name: null,
|
|
11
|
-
window: null,
|
|
12
|
-
modalities: [],
|
|
13
|
-
reasoningLevels: [],
|
|
14
|
-
pricing: null,
|
|
15
|
-
joinedFrom: null,
|
|
16
|
-
capabilities: 'unknown',
|
|
17
|
-
...overrides,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe('formatContextWindow', () => {
|
|
22
|
-
it('renders million- and thousand-token windows', () => {
|
|
23
|
-
expect(formatContextWindow(1_000_000)).toBe('1M');
|
|
24
|
-
expect(formatContextWindow(1_048_576)).toBe('1M');
|
|
25
|
-
expect(formatContextWindow(131_072)).toBe('131K');
|
|
26
|
-
expect(formatContextWindow(512)).toBe('512');
|
|
27
|
-
expect(formatContextWindow(null)).toBeNull();
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe('modelCapabilityChips', () => {
|
|
32
|
-
it('shows verified thinking, multimodal, and window chips', () => {
|
|
33
|
-
const chips = modelCapabilityChips(
|
|
34
|
-
entry({
|
|
35
|
-
id: 'gemini-3.5-flash',
|
|
36
|
-
window: 1_048_576,
|
|
37
|
-
modalities: ['audio', 'image', 'pdf', 'text', 'video'],
|
|
38
|
-
reasoningLevels: ['minimal', 'low', 'medium', 'high'],
|
|
39
|
-
joinedFrom: 'google/gemini-3.5-flash',
|
|
40
|
-
capabilities: 'known',
|
|
41
|
-
}),
|
|
42
|
-
);
|
|
43
|
-
expect(chips.map((chip) => chip.label)).toEqual([
|
|
44
|
-
'思考',
|
|
45
|
-
'图像',
|
|
46
|
-
'PDF',
|
|
47
|
-
'音频',
|
|
48
|
-
'视频',
|
|
49
|
-
'1M',
|
|
50
|
-
]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('puts reasoning levels on the thinking chip in the selected summary', () => {
|
|
54
|
-
const chips = modelCapabilityChips(
|
|
55
|
-
entry({
|
|
56
|
-
id: 'deepseek-v4-pro',
|
|
57
|
-
window: 1_000_000,
|
|
58
|
-
modalities: ['text'],
|
|
59
|
-
reasoningLevels: ['high', 'max'],
|
|
60
|
-
joinedFrom: 'deepseek/deepseek-v4-pro',
|
|
61
|
-
capabilities: 'known',
|
|
62
|
-
}),
|
|
63
|
-
{ detail: true },
|
|
64
|
-
);
|
|
65
|
-
expect(chips.map((chip) => chip.label)).toEqual(['思考(high / max)', '1M']);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('does not invent thinking or image chips from heuristic unknown rows', () => {
|
|
69
|
-
expect(
|
|
70
|
-
modelCapabilityChips(
|
|
71
|
-
entry({
|
|
72
|
-
id: 'deepseek-flash',
|
|
73
|
-
window: 131_072,
|
|
74
|
-
modalities: ['text'],
|
|
75
|
-
reasoningLevels: [],
|
|
76
|
-
capabilities: 'unknown',
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
79
|
-
).toEqual([
|
|
80
|
-
expect.objectContaining({ key: 'unknown', label: '未识别' }),
|
|
81
|
-
]);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('omits chips when capabilities was not reported', () => {
|
|
85
|
-
expect(modelCapabilityChips({ id: 'custom-model' } as GatewayModelEntry)).toEqual([]);
|
|
86
|
-
expect(modelCapabilityChips(null)).toEqual([]);
|
|
87
|
-
});
|
|
88
|
-
});
|