Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
buildCursorListModelsAgentEnv,
|
|
buildSdkRuntimeModelCacheKey,
|
|
canonicalizeEffortEncodedModelId,
|
|
createSdkRuntimeModelCache,
|
|
agentModelPresetsShallowEqual,
|
|
mergeFallbackThinkingLevels,
|
|
modelPresetsContainId,
|
|
normalizeStoredAgentModelSelection,
|
|
normalizeSdkRuntimeModelPresets,
|
|
shouldAdoptSdkCurrentModel,
|
|
shouldLoadSdkRuntimeModels,
|
|
shouldUseStoredAgentModel,
|
|
} from './AIChatSidePanelHelpers';
|
|
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
|
|
|
test('buildCursorListModelsAgentEnv injects cli-login auth mode for list-models', () => {
|
|
assert.deepEqual(
|
|
buildCursorListModelsAgentEnv({
|
|
command: '/Users/me/.local/bin/cursor-agent',
|
|
cursorAuthMode: 'cli-login',
|
|
env: { HOME: '/Users/me' },
|
|
}),
|
|
{
|
|
HOME: '/Users/me',
|
|
NETCATTY_CURSOR_AUTH_MODE: 'cli-login',
|
|
NETCATTY_CURSOR_CLI_BIN: '/Users/me/.local/bin/cursor-agent',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('buildCursorListModelsAgentEnv defaults to api-key without injecting CLI bin', () => {
|
|
assert.deepEqual(
|
|
buildCursorListModelsAgentEnv({
|
|
command: 'cursor',
|
|
env: {},
|
|
}),
|
|
{
|
|
NETCATTY_CURSOR_AUTH_MODE: 'api-key',
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
buildCursorListModelsAgentEnv({
|
|
command: 'cursor',
|
|
cursorAuthMode: 'api-key',
|
|
}),
|
|
{
|
|
NETCATTY_CURSOR_AUTH_MODE: 'api-key',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('Cursor auth mode changes invalidate the SDK model cache key', () => {
|
|
const base = {
|
|
id: 'managed_cursor',
|
|
command: '/Users/me/.local/bin/cursor-agent',
|
|
sdkBackend: 'cursor',
|
|
};
|
|
const apiKey = buildSdkRuntimeModelCacheKey({
|
|
...base,
|
|
cursorAuthMode: 'api-key',
|
|
});
|
|
const cliLogin = buildSdkRuntimeModelCacheKey({
|
|
...base,
|
|
cursorAuthMode: 'cli-login',
|
|
});
|
|
assert.notEqual(apiKey, cliLogin);
|
|
});
|
|
|
|
test('canonicalizeEffortEncodedModelId collapses Cursor query-string effort', () => {
|
|
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low'), 'gpt-5/low');
|
|
assert.equal(canonicalizeEffortEncodedModelId('gpt-5/high'), 'gpt-5/high');
|
|
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low&mode=fast'), 'gpt-5?effort=low&mode=fast');
|
|
});
|
|
|
|
test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () => {
|
|
const merged = mergeFallbackThinkingLevels(
|
|
[{ id: 'gpt-5.5', name: 'GPT-5.5' }, { id: 'auto', name: 'Auto' }],
|
|
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
|
);
|
|
assert.deepEqual(merged[0]?.thinkingLevels, ['low', 'medium', 'high']);
|
|
assert.equal(merged[0]?.defaultThinkingLevel, 'medium');
|
|
assert.equal(merged[1]?.thinkingLevels, undefined);
|
|
const partial = mergeFallbackThinkingLevels(
|
|
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }],
|
|
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
|
);
|
|
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
|
|
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
|
|
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
|
|
assert.equal(
|
|
mergeFallbackThinkingLevels(
|
|
alreadyFilled,
|
|
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'] }],
|
|
),
|
|
alreadyFilled,
|
|
);
|
|
});
|
|
|
|
test('agentModelPresetsShallowEqual ignores array identity when contents match', () => {
|
|
const left = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
|
|
const right = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
|
|
assert.equal(agentModelPresetsShallowEqual(left, right), true);
|
|
assert.equal(
|
|
agentModelPresetsShallowEqual(left, [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['high'] }]),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
|
|
const presets: AgentModelPreset[] = [
|
|
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
|
|
{ id: 'claude-sonnet', name: 'Claude Sonnet' },
|
|
];
|
|
|
|
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
|
|
assert.equal(modelPresetsContainId(presets, 'gpt-5.5?effort=high'), true);
|
|
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
|
|
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
|
|
});
|
|
|
|
test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () => {
|
|
const agent = (sdkBackend: string): ExternalAgentConfig => ({
|
|
id: `discovered_${sdkBackend}`,
|
|
name: sdkBackend,
|
|
command: sdkBackend,
|
|
enabled: true,
|
|
sdkBackend,
|
|
});
|
|
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('cursor')), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
|
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
|
|
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
|
});
|
|
|
|
test('Codex App Server model discovery uses a separate cache identity', () => {
|
|
const sdk = buildSdkRuntimeModelCacheKey({
|
|
id: 'discovered_codex',
|
|
command: '/bin/codex',
|
|
sdkBackend: 'codex',
|
|
codexRuntime: 'sdk',
|
|
});
|
|
const appServer = buildSdkRuntimeModelCacheKey({
|
|
id: 'discovered_codex',
|
|
command: '/bin/codex',
|
|
sdkBackend: 'codex',
|
|
codexRuntime: 'app-server',
|
|
});
|
|
assert.notEqual(sdk, appServer);
|
|
});
|
|
|
|
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
|
|
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', undefined, []), true);
|
|
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'openai/gpt-5.1', []), true);
|
|
assert.equal(
|
|
shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'anthropic/claude-sonnet', [
|
|
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
|
]),
|
|
false,
|
|
);
|
|
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
|
|
});
|
|
|
|
test('normalizeStoredAgentModelSelection rewrites Cursor query effort but not extra params', () => {
|
|
const presets: AgentModelPreset[] = [{
|
|
id: 'gpt-5.5',
|
|
name: 'GPT-5.5',
|
|
thinkingLevels: ['low', 'medium', 'high'],
|
|
defaultThinkingLevel: 'medium',
|
|
}];
|
|
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5?effort=low', presets), 'gpt-5.5/low');
|
|
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5/high', presets), 'gpt-5.5/high');
|
|
assert.equal(
|
|
normalizeStoredAgentModelSelection('gpt-5.5?effort=low&mode=fast', presets),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', () => {
|
|
const presets: AgentModelPreset[] = [{
|
|
id: 'glm-5.1',
|
|
name: 'GLM 5.1',
|
|
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
|
|
defaultThinkingLevel: 'medium',
|
|
encodeDefaultThinking: false,
|
|
}];
|
|
assert.equal(normalizeStoredAgentModelSelection('glm-5.1', presets), 'glm-5.1');
|
|
assert.equal(normalizeStoredAgentModelSelection('glm-5.1/high', presets), 'glm-5.1/high');
|
|
});
|
|
|
|
test('legacy plain model selections keep their model and gain its default reasoning effort', () => {
|
|
const presets: AgentModelPreset[] = [{
|
|
id: 'grok-4.5',
|
|
name: 'Grok 4.5',
|
|
thinkingLevels: ['high', 'medium', 'low'],
|
|
defaultThinkingLevel: 'high',
|
|
}, {
|
|
id: 'grok-4.6',
|
|
name: 'Grok 4.6',
|
|
thinkingLevels: ['xhigh', 'high', 'medium', 'low'],
|
|
defaultThinkingLevel: 'high',
|
|
}];
|
|
|
|
assert.equal(modelPresetsContainId(presets, 'grok-4.5'), true);
|
|
assert.equal(normalizeStoredAgentModelSelection('grok-4.5', presets), 'grok-4.5/high');
|
|
assert.equal(normalizeStoredAgentModelSelection('grok-4.5/medium', presets), 'grok-4.5/medium');
|
|
assert.equal(
|
|
shouldAdoptSdkCurrentModel('grok-4.6/high', 'grok-4.5', presets),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
|
|
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
|
|
{ id: 'custom/provider-model', name: 'custom/provider-model' },
|
|
]);
|
|
assert.deepEqual(normalizeSdkRuntimeModelPresets([], null), []);
|
|
assert.deepEqual(
|
|
normalizeSdkRuntimeModelPresets([{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }], 'custom/provider-model'),
|
|
[{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
);
|
|
});
|
|
|
|
test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is returned', () => {
|
|
const opencodeAgent: ExternalAgentConfig = {
|
|
id: 'managed_opencode',
|
|
name: 'OpenCode',
|
|
command: 'opencode',
|
|
enabled: true,
|
|
sdkBackend: 'opencode',
|
|
};
|
|
|
|
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], opencodeAgent), true);
|
|
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], undefined), false);
|
|
assert.equal(
|
|
shouldUseStoredAgentModel('anthropic/claude-sonnet', [
|
|
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
|
], opencodeAgent),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldUseStoredAgentModel('openai/gpt-5.1', [
|
|
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
|
], opencodeAgent),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('SDK runtime model cache coalesces concurrent refreshes', async () => {
|
|
const cache = createSdkRuntimeModelCache();
|
|
let calls = 0;
|
|
let resolveLoad!: (value: { currentModelId: string | null; models: AgentModelPreset[] }) => void;
|
|
const load = () => {
|
|
calls += 1;
|
|
return new Promise<{ currentModelId: string | null; models: AgentModelPreset[] }>((resolve) => {
|
|
resolveLoad = resolve;
|
|
});
|
|
};
|
|
|
|
const first = cache.refresh('opencode:/opt/bin/opencode', load);
|
|
const second = cache.refresh('opencode:/opt/bin/opencode', load);
|
|
|
|
assert.equal(first, second);
|
|
assert.equal(calls, 1);
|
|
|
|
resolveLoad({
|
|
currentModelId: 'openai/gpt-5.1',
|
|
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
});
|
|
assert.deepEqual(await first, {
|
|
currentModelId: 'openai/gpt-5.1',
|
|
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
});
|
|
});
|
|
|
|
test('SDK runtime model cache serves cached models while background refresh updates them', async () => {
|
|
let now = 1_000;
|
|
const cache = createSdkRuntimeModelCache({ now: () => now });
|
|
|
|
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
|
currentModelId: 'openai/gpt-5.1',
|
|
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
}));
|
|
|
|
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
|
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
|
]);
|
|
|
|
now = 2_000;
|
|
const refresh = cache.refresh(
|
|
'opencode:/opt/bin/opencode',
|
|
async () => ({
|
|
currentModelId: 'anthropic/claude-sonnet-4-6',
|
|
models: [{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }],
|
|
}),
|
|
{ force: true },
|
|
);
|
|
|
|
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
|
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
|
]);
|
|
|
|
await refresh;
|
|
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
|
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
|
]);
|
|
});
|
|
|
|
test('SDK runtime model cache keeps the previous catalog when a refresh fails', async () => {
|
|
const cache = createSdkRuntimeModelCache();
|
|
|
|
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
|
currentModelId: 'openai/gpt-5.1',
|
|
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
}));
|
|
|
|
await assert.rejects(
|
|
cache.refresh(
|
|
'opencode:/opt/bin/opencode',
|
|
async () => {
|
|
throw new Error('OpenCode unavailable');
|
|
},
|
|
{ force: true },
|
|
),
|
|
/OpenCode unavailable/,
|
|
);
|
|
|
|
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
|
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
|
]);
|
|
});
|
|
|
|
test('SDK runtime model cache ignores degraded empty catalogs', async () => {
|
|
const cache = createSdkRuntimeModelCache();
|
|
|
|
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
|
currentModelId: 'openai/gpt-5.1',
|
|
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
}));
|
|
|
|
const refreshed = await cache.refresh(
|
|
'opencode:/opt/bin/opencode',
|
|
async () => ({ currentModelId: null, models: [] }),
|
|
{ force: true },
|
|
);
|
|
|
|
assert.deepEqual(refreshed.models, [
|
|
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
|
]);
|
|
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
|
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
|
]);
|
|
|
|
const emptyCache = createSdkRuntimeModelCache();
|
|
await emptyCache.refresh('opencode:/usr/bin/opencode', async () => ({
|
|
currentModelId: null,
|
|
models: [],
|
|
}));
|
|
assert.equal(emptyCache.read('opencode:/usr/bin/opencode'), null);
|
|
});
|
|
|
|
test('SDK runtime model cache evicts expired catalogs', async () => {
|
|
let now = 1_000;
|
|
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
|
|
|
|
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
|
currentModelId: 'openai/gpt-5.1',
|
|
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
|
}));
|
|
assert.equal(cache.size(), 1);
|
|
|
|
now = 1_101;
|
|
assert.equal(cache.read('opencode:/opt/bin/opencode'), null);
|
|
assert.equal(cache.size(), 0);
|
|
});
|
|
|
|
test('SDK runtime model cache stays bounded under agent configuration churn', async () => {
|
|
const cache = createSdkRuntimeModelCache({ maxEntries: 4 });
|
|
|
|
for (let index = 0; index < 20; index += 1) {
|
|
await cache.refresh(`agent:${index}`, async () => ({
|
|
currentModelId: `model-${index}`,
|
|
models: [{ id: `model-${index}`, name: `Model ${index}` }],
|
|
}));
|
|
}
|
|
|
|
assert.equal(cache.size(), 4);
|
|
assert.equal(cache.read('agent:0'), null);
|
|
assert.equal(cache.read('agent:19')?.currentModelId, 'model-19');
|
|
});
|