Files
hapi/cli/src/modules/common/cursorModels.test.ts
T
743c0a06ec test(cursor): isolate cursor-models shared-cache tests per HAPI_HOME (#1268)
The four cursorModels* CLI test files share one on-disk cache path
($HAPI_HOME/cache/cursor-models.json, defaulting to /tmp/hapi when
HAPI_HOME is unset). Two files already isolate it (cursorModelsStaleLock
via a PID-namespaced home; handlers/cursorModels via a unique temp home),
but cursorModels.test.ts and cursorModelsSharedCache.test.ts do not.

Under vitest's parallel file execution, cursorModelsSharedCache's
afterEach(_resetSharedCursorModelsCacheForTests) rmSyncs that shared file
between the other file's write and read, so the read returns null and
"inherits cliModelSkus from shared cache" fails with
`expected undefined to deeply equal [...]`. Passes in isolation; fails at
random in the full parallel suite.

Give both un-isolated files their own mkdtemp HAPI_HOME at module load so
each test file's cache path is unique regardless of worker-process reuse.

Fixes heavygee/hapi#101

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:43:33 +01:00

388 lines
14 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest'
import { setCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Isolate the on-disk cursor-models cache to this file's own HAPI_HOME so
// parallel vitest workers don't race on the shared $HAPI_HOME/cache path
// (see heavygee/hapi#101).
process.env.HAPI_HOME = mkdtempSync(join(tmpdir(), 'hapi-cursor-models-'))
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
vi.mock('@/agent/backends/acp/agentCliGuard', () => ({
isAgentAcpTransportActive: vi.fn(() => false),
_resetAgentCliGuardForTests: vi.fn()
}));
const acpProbeMock = vi.hoisted(() => ({
runCursorAcpModelProbe: vi.fn()
}));
vi.mock('./cursorAcpModelProbe', () => ({
runCursorAcpModelProbe: acpProbeMock.runCursorAcpModelProbe,
cursorProbeResponseHasWireCatalog: (response: { success?: boolean; availableModels?: Array<{ modelId: string }> }) =>
response.success === true
&& (response.availableModels ?? []).some((model) => model.modelId.includes('['))
}));
import { isAgentAcpTransportActive } from '@/agent/backends/acp/agentCliGuard';
import {
readSharedCursorModelsCache,
writeSharedCursorModelsCache,
_resetSharedCursorModelsCacheForTests
} from './cursorModelsSharedCache';
import {
_resetCursorModelsCacheForTests,
buildCursorModelsSeedPayload,
listCursorModels,
parseCursorModelsOutput,
seedCursorModelsCache
} from './cursorModels';
afterEach(() => {
_resetCursorModelsCacheForTests()
_resetSharedCursorModelsCacheForTests()
setCursorAcpModelsSnapshot(null)
vi.mocked(isAgentAcpTransportActive).mockReturnValue(false)
spawnMock.mockReset()
acpProbeMock.runCursorAcpModelProbe.mockReset()
})
describe('buildCursorModelsSeedPayload', () => {
test('inherits cliModelSkus from shared cache when ACP snapshot has none', () => {
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
})
const seeded = buildCursorModelsSeedPayload(
{
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
},
readSharedCursorModelsCache()
)
expect(seeded.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-medium',
'gpt-5.5-high'
])
})
})
describe('parseCursorModelsOutput', () => {
test('parses Cursor agent model list output', () => {
const result = parseCursorModelsOutput(`
Available models
auto - Auto
composer-2.5 - Composer 2.5 (current)
composer-2.5-fast - Composer 2.5 Fast (default)
gpt-5.5-high-fast - GPT-5.5 High Fast
Tip: use --model <id> (or /model <id> in interactive mode) to switch.
`)
expect(result).toEqual({
availableModels: [
{ modelId: 'auto', name: 'Auto' },
{ modelId: 'composer-2.5', name: 'Composer 2.5' },
{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' },
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
],
currentModelId: 'composer-2.5'
})
})
test('uses default as current when Cursor output has no current marker', () => {
const result = parseCursorModelsOutput(`
Available models
composer-2.5-fast - Composer 2.5 Fast (default)
composer-2.5 - Composer 2.5
`)
expect(result.currentModelId).toBe('composer-2.5-fast')
})
})
describe('listCursorModels', () => {
test('does not spawn agent --list-models while ACP transport is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
currentModelId: 'composer-2.5[fast=true]'
})
const result = await listCursorModels()
expect(result).toEqual({
success: true,
availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
currentModelId: 'composer-2.5[fast=true]'
})
expect(spawnMock).not.toHaveBeenCalled()
})
test('returns empty list while ACP is active but no snapshot is seeded yet', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
const result = await listCursorModels()
expect(result).toEqual({
success: true,
availableModels: [],
currentModelId: null
})
expect(spawnMock).not.toHaveBeenCalled()
})
test('prefers shared on-disk cache over stale in-memory cache while ACP lock is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
seedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'stale-cli-sku' }],
currentModelId: 'stale-cli-sku'
})
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
currentModelId: 'composer-2.5[fast=true]'
})
const result = await listCursorModels()
expect(result.availableModels).toEqual([
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }
])
expect(spawnMock).not.toHaveBeenCalled()
})
test('reads shared on-disk cache while ACP lock is active in another process', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
currentModelId: 'composer-2.5[fast=true]'
})
const result = await listCursorModels()
expect(result.availableModels).toEqual([
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }
])
expect(spawnMock).not.toHaveBeenCalled()
expect(readSharedCursorModelsCache()?.currentModelId).toBe('composer-2.5[fast=true]')
})
test('unions partial response cliModelSkus with fuller shared cache while lock is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
setCursorAcpModelsSnapshot({
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' }
]
} as never)
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' },
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
})
const result = await listCursorModels()
expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-high-fast',
'gpt-5.5-low',
'gpt-5.5-medium',
'gpt-5.5-high'
])
expect(spawnMock).not.toHaveBeenCalled()
})
test('enriches live ACP snapshot with fuller shared cliModelSkus while lock is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
setCursorAcpModelsSnapshot({
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-low', name: 'GPT-5.5 1M Low' },
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high', name: 'GPT-5.5 High' }
]
})
const result = await listCursorModels()
expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-high-fast',
'gpt-5.5-low',
'gpt-5.5-medium',
'gpt-5.5-high'
])
expect(spawnMock).not.toHaveBeenCalled()
})
test('unions shared partial cliModelSkus with probe results when lock is inactive', async () => {
writeSharedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
currentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
cliModelSkus: [
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }
]
})
spawnMock.mockImplementation(() => ({
stdout: {
on: vi.fn((event: string, handler: (chunk: Buffer) => void) => {
if (event === 'data') {
handler(Buffer.from(
'gpt-5.5-high-fast - GPT-5.5 High Fast\n'
+ 'gpt-5.5-high - GPT-5.5 High\n'
));
}
})
},
stderr: { on: vi.fn() },
on: vi.fn((event: string, handler: (code: number) => void) => {
if (event === 'exit') {
setTimeout(() => handler(0), 0);
}
}),
kill: vi.fn()
}));
const result = await listCursorModels();
expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([
'gpt-5.5-medium',
'gpt-5.5-high-fast',
'gpt-5.5-high'
]);
expect(spawnMock).toHaveBeenCalled();
});
test('prefers ACP wire probe over CLI slug probe when cache is empty', async () => {
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
success: true,
availableModels: [{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }],
currentModelId: 'composer-2.5[fast=true]'
});
spawnMock.mockImplementation(() => ({
stdout: {
on: vi.fn((event: string, handler: (chunk: Buffer) => void) => {
if (event === 'data') {
handler(Buffer.from('composer-2.5-fast - Composer 2.5 Fast\n'));
}
})
},
stderr: { on: vi.fn() },
on: vi.fn((event: string, handler: (code: number) => void) => {
if (event === 'exit') {
setTimeout(() => handler(0), 0);
}
}),
kill: vi.fn()
}));
const result = await listCursorModels();
expect(acpProbeMock.runCursorAcpModelProbe).toHaveBeenCalled();
expect(result.availableModels).toEqual([
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }
]);
expect(result.cliModelSkus?.some((row) => row.modelId === 'composer-2.5-fast')).toBe(true);
});
test('does not cache CLI slug probe when ACP probe returns no wires', async () => {
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
success: false,
error: 'no acp'
});
spawnMock.mockImplementation(() => ({
stdout: {
on: vi.fn((event: string, handler: (chunk: Buffer) => void) => {
if (event === 'data') {
handler(Buffer.from('composer-2.5 - Composer 2.5 (current)\n'));
}
})
},
stderr: { on: vi.fn() },
on: vi.fn((event: string, handler: (code: number) => void) => {
if (event === 'exit') {
setTimeout(() => handler(0), 0);
}
}),
kill: vi.fn()
}));
const result = await listCursorModels();
expect(result).toEqual({
success: true,
availableModels: [],
currentModelId: null
});
});
test('skips CLI slug probe when ACP lock is active after ACP probe', async () => {
vi.mocked(isAgentAcpTransportActive)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
acpProbeMock.runCursorAcpModelProbe.mockResolvedValue({
success: false,
error: 'no wires'
});
const result = await listCursorModels();
expect(spawnMock).not.toHaveBeenCalled();
expect(result).toEqual({ success: false, error: 'no wires' });
});
test('prefers live ACP snapshot over cache while ACP transport is active', async () => {
vi.mocked(isAgentAcpTransportActive).mockReturnValue(true)
seedCursorModelsCache({
success: true,
availableModels: [{ modelId: 'stale' }],
currentModelId: 'stale'
})
setCursorAcpModelsSnapshot({
availableModels: [{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }],
currentModelId: 'composer-2.5-fast'
})
const result = await listCursorModels()
expect(result.currentModelId).toBe('composer-2.5-fast')
})
})