mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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>
388 lines
14 KiB
TypeScript
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')
|
|
})
|
|
})
|