diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 1aa32cda..3fa9897d 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -5,6 +5,7 @@ This guide covers the local Cursor integration in CCS, including CLI setup, daem ## What It Provides - OpenAI-compatible local endpoint powered by Cursor credentials. +- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. - Cursor model list and chat completions via local daemon. - Dedicated dashboard page: `ccs config` -> `Cursor IDE`. @@ -61,6 +62,7 @@ ccs cursor stop - `auto_start`: disabled - Model list resolution: authenticated live fetch when available, with cached/default fallback. - Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. +- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. These values are managed in unified config and can be updated from CLI or dashboard. diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts new file mode 100644 index 00000000..6575fd14 --- /dev/null +++ b/src/cursor/cursor-anthropic-response.ts @@ -0,0 +1,249 @@ +import { DeltaAccumulator } from '../glmt/delta-accumulator'; +import { GlmtTransformer } from '../glmt/glmt-transformer'; +import { SSEParser } from '../glmt/sse-parser'; +import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; + +const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; +const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; +type ResponseHeaders = Headers | Record | Array<[string, string]>; + +interface AnthropicErrorPayload { + type: 'error'; + error: { + type: string; + message: string; + }; +} + +function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { + return { + type: 'error', + error: { + type, + message, + }, + }; +} + +function formatErrorForLog(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function logTranslationError(context: string, error: unknown): void { + console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`); +} + +export function createAnthropicErrorResponse( + status: number, + type: string, + message: string, + headers?: ResponseHeaders +): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set('Content-Type', 'application/json'); + responseHeaders.delete('Content-Length'); + + return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { + status, + headers: responseHeaders, + }); +} + +function formatSseEvent(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function hasTranslatableChoices(value: unknown): value is OpenAIResponse { + if (typeof value !== 'object' || value === null) { + return false; + } + + const { choices } = value as OpenAIResponse; + if (!Array.isArray(choices) || choices.length === 0) { + return false; + } + + const firstChoice = choices[0]; + if (typeof firstChoice !== 'object' || firstChoice === null) { + return false; + } + + const message = (firstChoice as { message?: unknown }).message; + return typeof message === 'object' && message !== null; +} + +function isSyntheticTransformationFallback(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.startsWith('msg_error_') + ); +} + +async function createAnthropicErrorProxyResponse(response: Response): Promise { + const headers = new Headers(response.headers); + headers.delete('Content-Type'); + headers.delete('Content-Length'); + + let type = + response.status === 401 + ? 'authentication_error' + : response.status === 429 + ? 'rate_limit_error' + : response.status >= 400 && response.status < 500 + ? 'invalid_request_error' + : 'api_error'; + let message = `Cursor request failed with status ${response.status}`; + + try { + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const payload = (await response.json()) as { + error?: { type?: string; message?: string }; + message?: string; + }; + + if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { + type = payload.error.type; + } + + if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { + message = payload.error.message; + } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { + message = payload.message; + } + } else { + const text = (await response.text()).trim(); + if (text.length > 0) { + message = text; + } + } + } catch (error) { + logTranslationError('Failed to parse Cursor error response', error); + } + + return createAnthropicErrorResponse(response.status, type, message, headers); +} + +async function createAnthropicJsonResponse(response: Response): Promise { + try { + const openAiResponse = await response.json(); + if (!hasTranslatableChoices(openAiResponse)) { + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + + const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); + if (isSyntheticTransformationFallback(anthropicResponse)) { + logTranslationError( + 'Cursor JSON translation produced synthetic fallback response', + anthropicResponse + ); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + + return new Response(JSON.stringify(anthropicResponse), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + logTranslationError('Cursor JSON translation failed', error); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } +} + +function createAnthropicStreamingResponse(response: Response): Response { + const body = response.body; + if (!body) { + return createAnthropicErrorResponse( + 502, + 'api_error', + 'Cursor stream ended before a response body was available' + ); + } + + const parser = new SSEParser({ throwOnMalformedJson: true }); + const transformer = new GlmtTransformer(); + const accumulator = new DeltaAccumulator({}); + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const reader = body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + const events = parser.parse(Buffer.from(value)); + events.forEach((event) => { + const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); + anthropicEvents.forEach((anthropicEvent) => { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + }); + }); + } + + if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { + transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + }); + } + } catch (error) { + logTranslationError('Cursor SSE translation failed', error); + controller.enqueue( + encoder.encode( + formatSseEvent( + 'error', + createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) + ) + ) + ); + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + return new Response(readable, { + status: response.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +export async function createAnthropicProxyResponse(response: Response): Promise { + if (!response.ok) { + return createAnthropicErrorProxyResponse(response); + } + + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isEventStream = + contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); + + return isEventStream + ? createAnthropicStreamingResponse(response) + : createAnthropicJsonResponse(response); +} diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts new file mode 100644 index 00000000..6dbe7d92 --- /dev/null +++ b/src/cursor/cursor-anthropic-translator.ts @@ -0,0 +1,216 @@ +import type { CursorTool } from './cursor-protobuf-schema'; +import type { + AnthropicContentBlock, + CursorAnthropicRequest, + CursorOpenAIMessage, +} from './cursor-anthropic-types'; + +export interface TranslatedAnthropicRequest { + model?: string; + stream: boolean; + reasoning_effort?: string; + tools?: CursorTool[]; + messages: CursorOpenAIMessage[]; +} + +const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; +const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; + +function assertObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function safeJsonStringify(value: unknown, fallback: string): string { + try { + const serialized = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : fallback; + } catch { + return fallback; + } +} + +function createFallbackToolId(messageIndex: number, blockIndex: number): string { + return `toolu_ccs_fallback_${messageIndex}_${blockIndex}`; +} + +function flattenTextContent(content: unknown, label: string): string { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + throw new Error(`${label} must be a string or content block array`); + } + + return content + .map((block, index) => { + const parsed = assertObject(block, `${label}[${index}]`); + if (parsed.type !== 'text') { + throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`); + } + return typeof parsed.text === 'string' ? parsed.text : ''; + }) + .join('\n'); +} + +function toToolResultContent(content: unknown, label: string): string { + if (content === undefined) { + return ''; + } + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return flattenTextContent(content, label); + } + return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); +} + +function mapThinkingToReasoningEffort( + thinking: CursorAnthropicRequest['thinking'] +): string | undefined { + if (!thinking) { + return undefined; + } + if (thinking.type === 'disabled') { + return undefined; + } + if (thinking.type !== 'enabled') { + throw new Error('thinking.type must be "enabled" or "disabled"'); + } + return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 + ? 'high' + : 'medium'; +} + +export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequest { + const request = assertObject(raw, 'request') as CursorAnthropicRequest; + const translatedMessages: CursorOpenAIMessage[] = []; + + if (request.system !== undefined) { + translatedMessages.push({ + role: 'system', + content: flattenTextContent(request.system, 'system'), + }); + } + + if (!Array.isArray(request.messages)) { + throw new Error('messages must be an array'); + } + + request.messages.forEach((message, messageIndex) => { + const role = message.role; + if (role !== 'user' && role !== 'assistant') { + throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`); + } + + const content = message.content; + if (typeof content === 'string') { + translatedMessages.push({ role, content }); + return; + } + + if (!Array.isArray(content)) { + throw new Error(`messages[${messageIndex}].content must be a string or array`); + } + + const textParts: string[] = []; + const toolCalls: NonNullable = []; + let sawToolResult = false; + + content.forEach((block, blockIndex) => { + const parsed = assertObject( + block, + `messages[${messageIndex}].content[${blockIndex}]` + ) as unknown as AnthropicContentBlock; + + if (parsed.type === 'text') { + textParts.push(typeof parsed.text === 'string' ? parsed.text : ''); + return; + } + + if (parsed.type === 'tool_use') { + if (role !== 'assistant') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` + ); + } + toolCalls.push({ + id: + typeof parsed.id === 'string' && parsed.id.length > 0 + ? parsed.id + : createFallbackToolId(messageIndex, blockIndex), + type: 'function', + function: { + name: typeof parsed.name === 'string' ? parsed.name : 'tool', + arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK), + }, + }); + return; + } + + if (parsed.type === 'tool_result') { + if (role !== 'user') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` + ); + } + if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string` + ); + } + sawToolResult = true; + if (textParts.length > 0) { + translatedMessages.push({ + role, + content: textParts.join('\n'), + }); + textParts.length = 0; + } + translatedMessages.push({ + role: 'tool', + tool_call_id: parsed.tool_use_id, + content: toToolResultContent( + parsed.content, + `messages[${messageIndex}].content[${blockIndex}].content` + ), + }); + return; + } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String((parsed as { type?: unknown }).type)}" is not supported` + ); + }); + + if (role === 'assistant') { + translatedMessages.push({ + role, + content: textParts.join('\n'), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); + return; + } + + if (textParts.length > 0 || !sawToolResult) { + translatedMessages.push({ + role, + content: textParts.join('\n'), + }); + } + }); + + return { + model: + typeof request.model === 'string' && request.model.trim().length > 0 + ? request.model + : undefined, + stream: request.stream === true, + reasoning_effort: mapThinkingToReasoningEffort(request.thinking), + tools: Array.isArray(request.tools) ? request.tools : undefined, + messages: translatedMessages, + }; +} diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts new file mode 100644 index 00000000..336fb75f --- /dev/null +++ b/src/cursor/cursor-anthropic-types.ts @@ -0,0 +1,48 @@ +import type { CursorTool } from './cursor-protobuf-schema'; + +export interface CursorOpenAIMessage { + role: string; + content: string; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +export interface AnthropicTextBlock { + type: 'text'; + text?: string; +} + +export interface AnthropicToolUseBlock { + type: 'tool_use'; + id?: string; + name?: string; + input?: Record; +} + +export interface AnthropicToolResultBlock { + type: 'tool_result'; + tool_use_id?: string; + content?: unknown; +} + +export type AnthropicContentBlock = + | AnthropicTextBlock + | AnthropicToolUseBlock + | AnthropicToolResultBlock; + +export interface CursorAnthropicRequest { + model?: string; + messages?: Array<{ role?: string; content?: string | AnthropicContentBlock[] }>; + system?: string | AnthropicTextBlock[]; + stream?: boolean; + tools?: CursorTool[]; + thinking?: { + type?: string; + budget_tokens?: number; + }; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index f6eb2f7d..bfcea61c 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -7,6 +7,11 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; +import { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from './cursor-anthropic-response'; +import { translateAnthropicRequest } from './cursor-anthropic-translator'; import { checkAuthStatus } from './cursor-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; @@ -190,10 +195,12 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const executor = new CursorExecutor(); const server = http.createServer(async (req, res) => { - try { - const method = req.method || 'GET'; - const requestUrl = req.url || '/'; + const method = req.method || 'GET'; + const requestUrl = req.url || '/'; + const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions'; + const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages'; + try { if (method === 'GET' && requestUrl === '/health') { writeJson(res, 200, { ok: true, service: 'cursor-daemon' }); return; @@ -222,13 +229,17 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } - if (method !== 'POST' || requestUrl !== '/v1/chat/completions') { + if (!isOpenAiRoute && !isAnthropicRoute) { writeJson(res, 404, { error: 'Not found' }); return; } - const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; - const messages = normalizeMessages(parsedBody.messages); + const rawBody = await readJsonBody(req); + const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined; + const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {}); + const messages = anthropicBody + ? anthropicBody.messages + : normalizeMessages(parsedBody.messages); const requestedModel = typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0 ? parsedBody.model.trim() @@ -237,22 +248,38 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const authStatus = checkAuthStatus(); if (!authStatus.authenticated || !authStatus.credentials) { - writeJson(res, 401, { - error: { - type: 'authentication_error', - message: 'Cursor credentials not found. Run `ccs cursor auth` first.', - }, - }); + const message = 'Cursor credentials not found. Run `ccs cursor auth` first.'; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(401, 'authentication_error', message), + res + ); + } else { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message, + }, + }); + } return; } if (authStatus.expired) { - writeJson(res, 401, { - error: { - type: 'authentication_error', - message: 'Cursor credentials expired. Run `ccs cursor auth` again.', - }, - }); + const message = 'Cursor credentials expired. Run `ccs cursor auth` again.'; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(401, 'authentication_error', message), + res + ); + } else { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message, + }, + }); + } return; } @@ -301,16 +328,28 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser }, }); - await pipeWebResponseToNode(result.response, res); + const outgoingResponse = isAnthropicRoute + ? await createAnthropicProxyResponse(result.response) + : result.response; + + await pipeWebResponseToNode(outgoingResponse, res); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; const isPayloadTooLarge = message.includes('Request body too large'); - writeJson(res, isPayloadTooLarge ? 413 : 400, { - error: { - type: 'invalid_request_error', - message, - }, - }); + const status = isPayloadTooLarge ? 413 : 400; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(status, 'invalid_request_error', message), + res + ); + } else { + writeJson(res, status, { + error: { + type: 'invalid_request_error', + message, + }, + }); + } } }); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 208a1a25..1bd9a31f 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -281,6 +281,48 @@ function getCatalogDefaultModelId(availableModels: CursorModel[]): string { return firstAvailable || DEFAULT_CURSOR_MODEL; } +function addLookupCandidate(candidates: Set, value: string): void { + const normalized = value.trim().toLowerCase(); + if (normalized) { + candidates.add(normalized); + } +} + +function buildCursorAnthropicModelLookupCandidates(requestedModel: string): string[] { + const candidates = new Set(); + const raw = requestedModel.trim().toLowerCase(); + addLookupCandidate(candidates, raw); + + let normalized = raw.replace(/^[a-z0-9_-]+\//, ''); + addLookupCandidate(candidates, normalized); + + while (true) { + const stripped = normalized + .replace(/\(\d+\)$/i, '') + .replace(/\[1m\]$/i, '') + .replace(/-thinking$/i, '') + .replace(/-\d{8}$/i, ''); + + if (stripped === normalized) { + break; + } + + normalized = stripped; + addLookupCandidate(candidates, normalized); + } + + const anthropicAliasMatch = normalized.match( + /^claude-(opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?(?:-(1m|fast-mode))?$/i + ); + if (anthropicAliasMatch) { + const [, family, major, minor, variant] = anthropicAliasMatch; + const cursorModelId = `claude-${major}${minor ? `.${minor}` : ''}-${family.toLowerCase()}${variant ? `-${variant.toLowerCase()}` : ''}`; + addLookupCandidate(candidates, cursorModelId); + } + + return [...candidates]; +} + export function resolveCursorRequestModel( requestedModel: string | null | undefined, availableModels: CursorModel[] @@ -291,8 +333,12 @@ export function resolveCursorRequestModel( return fallbackModel; } - if (availableModels.some((model) => model.id === normalizedRequested)) { - return normalizedRequested; + const lookupCandidates = new Set(buildCursorAnthropicModelLookupCandidates(normalizedRequested)); + const matchedModel = availableModels.find((model) => + lookupCandidates.has(model.id.toLowerCase()) + ); + if (matchedModel) { + return matchedModel.id; } return fallbackModel; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index eaeb00dd..9a731654 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -19,6 +19,7 @@ interface SSEParserOptions { maxBufferSize?: number; + throwOnMalformedJson?: boolean; } interface SSEEvent { @@ -33,11 +34,13 @@ export class SSEParser { private buffer: string; private eventCount: number; private maxBufferSize: number; + private throwOnMalformedJson: boolean; constructor(options: SSEParserOptions = {}) { this.buffer = ''; this.eventCount = 0; this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default + this.throwOnMalformedJson = options.throwOnMalformedJson === true; } /** @@ -92,6 +95,9 @@ export class SSEParser { data.substring(0, 100) ); } + if (this.throwOnMalformedJson) { + throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); + } } } } else if (line.startsWith('id: ')) { diff --git a/tests/README.md b/tests/README.md index 3f0091ed..3abf42e6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,7 +11,7 @@ tests/ ├── native/ # Native installation tests (bash/PowerShell) │ ├── unix/ # Unix/Linux/macOS tests │ └── windows/ # Windows PowerShell tests -├── integration/ # Integration tests (manual execution) +├── integration/ # Integration + smoke tests └── shared/ # Shared utilities ├── fixtures/ # Test configuration and environment ├── unit/ # Helper function tests @@ -22,9 +22,9 @@ tests/ ## Running Tests ```bash -bun run test # All automated tests (unit + npm) -bun run test:unit # Unit tests only (Mocha) -bun run test:npm # npm package tests (Mocha) +bun run test # All automated tests (unit + integration + npm) +bun run test:unit # Unit tests only +bun run test:npm # npm package tests bun run test:native # Native Unix tests (bash) ``` @@ -48,16 +48,18 @@ Installation tests for curl|bash (Unix) and irm|iex (Windows): - `native/windows/edge-cases.ps1` - Windows edge case tests ### Integration Tests (`integration/`) -Manual execution tests for specific scenarios: -- `token-counting-test.js` - Token counting validation -- `z-ai-streaming-test.js` - Z.AI streaming -- `glmt-integration-test.sh` - GLMT integration +Integration and smoke coverage for scenarios that exercise multiple layers: +- Automated `*.test.ts` files run as part of `bun run test:all` and CI +- Shell and standalone probe scripts remain on-demand for targeted debugging +- `cursor-daemon-lifecycle.test.ts` - local daemon process + HTTP smoke coverage +- `image-analyzer-hook.test.ts` - hook integration coverage +- `glmt-integration-test.sh` - GLMT integration probe - `symlink-chain-test.sh` - Symlink chain handling - `ux-integration-test.sh` - CLI UX integration ## Adding New Tests -- **Unit tests**: Add to `unit//` using Mocha + Node.js assert -- **npm tests**: Add to `npm/` using Mocha +- **Unit tests**: Add to `unit//` for isolated module behavior +- **npm tests**: Add to `npm/` for package behavior - **Native tests**: Add to `native/unix/` or `native/windows/` -- **Integration tests**: Add to `integration/` +- **Integration tests**: Add automated cross-layer smoke coverage to `integration/*.test.ts` diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts new file mode 100644 index 00000000..7a4e28ed --- /dev/null +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isDaemonRunning, startDaemon, stopDaemon } from '../../src/cursor/cursor-daemon'; +import { saveCredentials } from '../../src/cursor/cursor-auth'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-integration-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(async () => { + await stopDaemon(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +describe('cursor daemon lifecycle smoke', () => { + it('starts, serves expected routes, and stops cleanly', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); + + expect(await isDaemonRunning(port)).toBe(true); + + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); + expect(modelsResponse.status).toBe(200); + const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; + expect(modelsJson.object).toBe('list'); + expect(Array.isArray(modelsJson.data)).toBe(true); + + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); + + const anthropicResponse = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(anthropicResponse.status).toBe(401); + const anthropicBody = (await anthropicResponse.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(anthropicBody.type).toBe('error'); + expect(anthropicBody.error?.type).toBe('authentication_error'); + expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first'); + + const stopResult = await stopDaemon(); + expect(stopResult.success).toBe(true); + expect(await isDaemonRunning(port)).toBe(false); + }, 35000); + + it('returns 404 for unknown routes', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const response = await fetch(`http://127.0.0.1:${port}/unknown`); + expect(response.status).toBe(404); + }); + + it('returns 401 when credentials are expired', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: expiredAt, + }); + + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { error?: { message?: string } }; + expect(body.error?.message).toContain('expired'); + }); + + it('validates invalid JSON, invalid message schema, and oversized body', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid-json', + }); + expect(invalidJson.status).toBe(400); + + const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: { role: 'user', content: 'hello' }, + }), + }); + expect(invalidSchema.status).toBe(400); + + const invalidAnthropic = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }), + }); + expect(invalidAnthropic.status).toBe(400); + const invalidAnthropicBody = (await invalidAnthropic.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(invalidAnthropicBody.type).toBe('error'); + expect(invalidAnthropicBody.error?.type).toBe('invalid_request_error'); + expect(invalidAnthropicBody.error?.message).toContain('is not supported'); + + const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [ + { + role: 'user', + content: 'x'.repeat(10 * 1024 * 1024 + 1024), + }, + ], + }), + }); + expect(oversized.status).toBe(413); + }); +}); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts new file mode 100644 index 00000000..0abaec2b --- /dev/null +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, it } from 'bun:test'; +import { createAnthropicProxyResponse } from '../../../src/cursor/cursor-anthropic-response'; +import { translateAnthropicRequest } from '../../../src/cursor/cursor-anthropic-translator'; + +describe('translateAnthropicRequest', () => { + it('maps Anthropic system, tool use, and tool result blocks into Cursor OpenAI messages', () => { + const translated = translateAnthropicRequest({ + model: 'claude-sonnet-4.5', + stream: true, + thinking: { type: 'enabled', budget_tokens: 9000 }, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + system: 'You are helpful.', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Find release notes' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + content: [{ type: 'text', text: 'v7.53.0' }], + }, + { type: 'text', text: 'Summarize it.' }, + ], + }, + ], + }); + + expect(translated.model).toBe('claude-sonnet-4.5'); + expect(translated.stream).toBe(true); + expect(translated.reasoning_effort).toBe('high'); + expect(translated.messages).toEqual([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Find release notes' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"release"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'v7.53.0' }, + { role: 'user', content: 'Summarize it.' }, + ]); + }); + + it('rejects unsupported content blocks', () => { + expect(() => + translateAnthropicRequest({ + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }) + ).toThrow('is not supported'); + }); + + it('does not append an empty user message after tool_result-only turns', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'x' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"x"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, + ]); + }); + + it('preserves mixed user text around tool_result blocks in order', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'before' }, + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }, + { type: 'text', text: 'after' }, + ], + }, + ], + }); + + expect(translated.messages).toEqual([ + { role: 'user', content: 'before' }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, + { role: 'user', content: 'after' }, + ]); + }); + + it('handles empty messages arrays', () => { + const translated = translateAnthropicRequest({ messages: [] }); + + expect(translated.messages).toEqual([]); + }); + + it('uses a distinct fallback prefix for missing tool_use ids', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', name: 'search', input: { q: 'x' } }], + }, + ], + }); + + expect(translated.messages[0]?.tool_calls?.[0]?.id).toBe('toolu_ccs_fallback_0_0'); + }); + + it('falls back when tool_result content cannot be serialized', () => { + const circular: Record = {}; + circular.self = circular; + + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: circular }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_1', + content: '[unserializable content]', + }, + ]); + }); + + it('returns empty string for tool_result blocks without content', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1' }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_1', + content: '', + }, + ]); + }); + + it('rejects tool_result blocks without a non-empty tool_use_id', () => { + expect(() => + translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: ' ', content: 'done' }], + }, + ], + }) + ).toThrow('tool_use_id must be a non-empty string'); + }); + + it('falls back when tool_use input cannot be serialized', () => { + const circular: Record = {}; + circular.self = circular; + + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', name: 'search', input: circular }], + }, + ], + }); + + expect(translated.messages[0]).toEqual({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_ccs_fallback_0_0', + type: 'function', + function: { name: 'search', arguments: '{}' }, + }, + ], + }); + }); +}); + +describe('createAnthropicProxyResponse', () => { + it('converts OpenAI JSON into Anthropic message JSON', async () => { + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'claude-sonnet-4.5', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Here is the result.', + reasoning_content: 'Need to call the tool first.', + tool_calls: [ + { + id: 'toolu_2', + type: 'function', + function: { name: 'search', arguments: '{"q":"cursor daemon"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + type: string; + model: string; + stop_reason: string; + content: Array<{ + type: string; + text?: string; + thinking?: string; + name?: string; + input?: Record; + }>; + }; + + expect(body.type).toBe('message'); + expect(body.model).toBe('claude-sonnet-4.5'); + expect(body.stop_reason).toBe('tool_use'); + expect(body.content.map((block) => block.type)).toEqual(['thinking', 'text', 'tool_use']); + expect(body.content[0]?.thinking).toContain('Need to call the tool first'); + expect(body.content[2]?.name).toBe('search'); + expect(body.content[2]?.input).toEqual({ q: 'cursor daemon' }); + }); + + it('returns 502 when Cursor returns invalid JSON', async () => { + const transformed = await createAnthropicProxyResponse( + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns 502 when Cursor response is missing choices', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_missing_choices', + model: 'claude-sonnet-4.5', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns 502 when Cursor response has empty choices', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_empty_choices', + model: 'claude-sonnet-4.5', + choices: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns Anthropic error envelopes for non-OK upstream JSON errors', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + error: { + type: 'invalid_request_error', + message: '[400]: upstream rejected request', + }, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json', 'Retry-After': '7' }, + } + ) + ); + + expect(transformed.status).toBe(400); + expect(transformed.headers.get('retry-after')).toBe('7'); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toBe('[400]: upstream rejected request'); + }); + + it('returns 502 when Cursor response choices are malformed', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_missing_message', + model: 'claude-sonnet-4.5', + choices: [{ index: 0 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { + const openAiSse = [ + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAiSse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: content_block_start'); + expect(body).toContain('"type":"text_delta"'); + expect(body).toContain('event: message_stop'); + }); + + it('emits Anthropic-style error events when SSE translation fails', async () => { + const oversizedChunk = `data: ${'x'.repeat(1024 * 1024 + 32)}`; + + const transformed = await createAnthropicProxyResponse( + new Response(oversizedChunk, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"error"'); + expect(body).toContain('"error":{"type":"api_error"'); + expect(body).toContain('Failed to translate Cursor SSE response'); + }); + + it('emits Anthropic-style error events when SSE JSON is malformed', async () => { + const transformed = await createAnthropicProxyResponse( + new Response('data: {not-json}\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream; charset=utf-8' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"error"'); + expect(body).toContain('"error":{"type":"api_error"'); + expect(body).toContain('Failed to translate Cursor SSE response'); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 1ba7e553..3b5fc576 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -19,7 +19,7 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; -import { loadCredentials, saveCredentials } from '../../../src/cursor/cursor-auth'; +import { loadCredentials } from '../../../src/cursor/cursor-auth'; // Test isolation let originalCcsHome: string | undefined; @@ -140,132 +140,6 @@ describe('startDaemon', () => { expect(result.success).toBe(false); expect(result.error).toContain('Invalid port'); }); - - it('starts and stops daemon successfully', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - expect(result.pid).toBeDefined(); - - // Verify health - const running = await isDaemonRunning(port); - expect(running).toBe(true); - - // Verify models endpoint exists and is OpenAI-compatible list shape - const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); - expect(modelsResponse.status).toBe(200); - const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; - expect(modelsJson.object).toBe('list'); - expect(Array.isArray(modelsJson.data)).toBe(true); - - // Verify chat endpoint exists (requires auth, should not be 404) - const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - expect(chatResponse.status).toBe(401); - - // Stop - const stopResult = await stopDaemon(); - expect(stopResult.success).toBe(true); - - // Verify stopped - const stillRunning = await isDaemonRunning(port); - expect(stillRunning).toBe(false); - }, - 35000 - ); - - it('returns 404 for unknown routes', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - - try { - const response = await fetch(`http://127.0.0.1:${port}/unknown`); - expect(response.status).toBe(404); - } finally { - await stopDaemon(); - } - }); - - it('returns 401 when credentials are expired', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); - - saveCredentials({ - accessToken: 'a'.repeat(60), - machineId: '1234567890abcdef1234567890abcdef', - authMethod: 'manual', - importedAt: expiredAt, - }); - - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - - try { - const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - - expect(response.status).toBe(401); - const body = (await response.json()) as { error?: { message?: string } }; - expect(body.error?.message).toContain('expired'); - } finally { - await stopDaemon(); - } - }); - - it('validates invalid JSON, invalid message schema, and oversized body', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - - try { - const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{invalid-json', - }); - expect(invalidJson.status).toBe(400); - - const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: { role: 'user', content: 'hello' }, - }), - }); - expect(invalidSchema.status).toBe(400); - - const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [ - { - role: 'user', - content: 'x'.repeat(10 * 1024 * 1024 + 1024), - }, - ], - }), - }); - expect(oversized.status).toBe(413); - } finally { - await stopDaemon(); - } - }); }); describe('isDaemonRunning', () => { diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 0390f5e1..a5095b5e 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -57,6 +57,19 @@ describe('resolveCursorRequestModel', () => { expect(resolved).toBe('claude-4.6-opus'); }); + it('maps Anthropic family-first aliases to the matching Cursor model id', () => { + const resolved = resolveCursorRequestModel('claude-sonnet-4.5', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe('claude-4.5-sonnet'); + }); + + it('strips provider, dated, and thinking suffixes before resolving Anthropic aliases', () => { + const resolved = resolveCursorRequestModel( + 'anthropic/claude-sonnet-4.5-20250929-thinking', + DEFAULT_CURSOR_MODELS + ); + expect(resolved).toBe('claude-4.5-sonnet'); + }); + it('falls back to default when requested model is unavailable', () => { const resolved = resolveCursorRequestModel('non-existent-model', DEFAULT_CURSOR_MODELS); expect(resolved).toBe(DEFAULT_CURSOR_MODEL);