From f7ddad6c19353ae3b32e82541526a2149160ee36 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 2 Apr 2026 03:21:38 -0400 Subject: [PATCH] fix(cursor): harden runtime follow-up regressions --- src/auth/profile-detector.ts | 5 +- src/cursor/cursor-translator.ts | 3 +- .../hooks/image-analysis-backend-resolver.ts | 10 +++ tests/unit/auth/profile-detector.test.ts | 68 ++++++++++++------- .../cursor/cursor-profile-executor.test.ts | 9 +++ tests/unit/cursor/cursor-protobuf.test.ts | 25 ++++++- .../image-analysis-backend-resolver.test.ts | 38 +++++++++++ 7 files changed, 126 insertions(+), 32 deletions(-) diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index d21e187c..a67f8d98 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -21,7 +21,7 @@ import { CompositeVariantConfig, CompositeTierConfig, } from '../config/unified-config-types'; -import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; +import { loadUnifiedConfig, isUnifiedMode, getCursorConfig } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; @@ -308,8 +308,7 @@ class ProfileDetector { // Priority 0.75: Check Cursor profile - local Cursor daemon runtime if (profileName === 'cursor') { - const unifiedConfig = this.readUnifiedConfig(); - const cursorConfig = unifiedConfig?.cursor; + const cursorConfig = getCursorConfig(); if (!cursorConfig?.enabled) { const error = new Error( diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts index 72112568..c3c078da 100644 --- a/src/cursor/cursor-translator.ts +++ b/src/cursor/cursor-translator.ts @@ -119,7 +119,8 @@ function escapeXml(text: string): string { } function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string { - const cleanResult = truncateToolResultText(escapeXml(sanitizeToolResultText(resultText))); + // Truncate raw tool output before XML escaping so the cap reflects original content. + const cleanResult = escapeXml(truncateToolResultText(sanitizeToolResultText(resultText))); return [ '', diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index ec7921e4..2acae204 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -396,6 +396,16 @@ function resolveBackend( }; } + if (profileType === 'cursor' || profileName === 'cursor') { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unresolved', + reason: + 'Cursor image analysis does not inherit the global fallback backend. Set image_analysis.profile_backends.cursor to an explicit provider to enable transformer-backed image analysis.', + }; + } + if (profileType === 'copilot' || profileName === 'copilot') { const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models)); return { diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index f46f2319..84d57415 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -197,21 +197,14 @@ describe('ProfileDetector', () => { }); it('should detect cursor as a first-class runtime profile when enabled', () => { - const mockUnifiedConfig = { - version: 2, - cursor: { - enabled: true, - port: 20129, - auto_start: true, - ghost_mode: true, - model: 'gpt-5.3-codex', - }, - }; - const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); - const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( - mockUnifiedConfig as any - ); + const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ + enabled: true, + port: 20129, + auto_start: true, + ghost_mode: true, + model: 'gpt-5.3-codex', + }); try { const result = detector.detectProfileType('cursor'); @@ -220,32 +213,55 @@ describe('ProfileDetector', () => { expect(result.cursorConfig?.auto_start).toBe(true); } finally { isUnifiedModeSpy.mockRestore(); - loadUnifiedConfigSpy.mockRestore(); + getCursorConfigSpy.mockRestore(); } }); - it('should throw a helpful error when cursor profile is disabled', () => { - const mockUnifiedConfig = { - version: 2, - cursor: { - enabled: false, + it('should merge default cursor fields when enabled via partial unified config', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'cursor:', ' enabled: true'].join('\n') + ); + + try { + const localDetector = new ProfileDetector(); + const result = localDetector.detectProfileType('cursor'); + expect(result.type).toBe('cursor'); + expect(result.cursorConfig).toEqual({ + enabled: true, port: 20129, auto_start: false, ghost_mode: true, model: 'gpt-5.3-codex', - }, - }; + }); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('should throw a helpful error when cursor profile is disabled', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); - const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( - mockUnifiedConfig as any - ); + const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', + }); try { expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); } finally { isUnifiedModeSpy.mockRestore(); - loadUnifiedConfigSpy.mockRestore(); + getCursorConfigSpy.mockRestore(); } }); }); diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index e655c882..c0c2a1fc 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import { executeCursorProfile, generateCursorEnv, + resolveCursorImageAnalysisEnv, } from '../../../src/cursor/cursor-profile-executor'; import { saveCredentials } from '../../../src/cursor/cursor-auth'; import type { CursorConfig } from '../../../src/config/unified-config-types'; @@ -57,6 +58,14 @@ describe('cursor-profile-executor', () => { expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/claude-config'); }); + it('skips image-analysis provider routing for cursor unless explicitly mapped', async () => { + const { env, warning } = await resolveCursorImageAnalysisEnv(); + + expect(env.CCS_CURRENT_PROVIDER).toBe(''); + expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1'); + expect(warning).toBeNull(); + }); + it('fails fast when Cursor integration is disabled', async () => { const exitCode = await executeCursorProfile({ ...BASE_CONFIG, enabled: false }, []); expect(exitCode).toBe(1); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index d3cdf709..c2bdbd3b 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -22,6 +22,25 @@ import { CursorExecutor } from '../../../src/cursor/cursor-executor'; import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema'; import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser'; +const MAX_TOOL_RESULT_CHARS = 12_000; + +function computeExpectedToolResultOmittedChars(textLength: number): number { + if (textLength <= MAX_TOOL_RESULT_CHARS) { + return 0; + } + + let omittedChars = textLength - MAX_TOOL_RESULT_CHARS; + while (true) { + const suffix = `\n[truncated ${omittedChars} chars]`; + const keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); + const nextOmittedChars = textLength - keepLength; + if (nextOmittedChars === omittedChars) { + return omittedChars; + } + omittedChars = nextOmittedChars; + } +} + describe('Protobuf Encoding/Decoding', () => { describe('encodeVarint / decodeVarint round-trip', () => { it('should encode and decode 0', () => { @@ -555,6 +574,8 @@ describe('Message Translation', () => { it('should truncate oversized tool result payloads', () => { const oversizedResult = '&'.repeat(12_050); + const omittedChars = computeExpectedToolResultOmittedChars(oversizedResult.length); + const preservedChars = oversizedResult.length - omittedChars; const result = buildCursorRequest( 'gpt-4', { @@ -583,11 +604,11 @@ describe('Message Translation', () => { expect(result.messages).toHaveLength(2); expect(result.messages[1].content).toContain('[truncated '); - expect(result.messages[1].content.length).toBeLessThan(12_250); + expect(result.messages[1].content).toContain(`[truncated ${omittedChars} chars]`); const resultMatch = result.messages[1].content.match(/([\s\S]*)<\/result>/); expect(resultMatch).not.toBeNull(); - expect(resultMatch?.[1].length).toBeLessThanOrEqual(12_000); + expect((resultMatch?.[1].match(/&/g) ?? []).length).toBe(preservedChars); }); it('should mark unserializable structured tool results explicitly', () => { diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 84d1ffdb..7525e7d3 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -44,6 +44,44 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('copilot-alias'); }); + it('does not route cursor image analysis through the fallback backend by default', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'cursor', + profileType: 'cursor', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(false); + expect(status.backendId).toBeNull(); + expect(status.status).toBe('skipped'); + expect(status.resolutionSource).toBe('unresolved'); + expect(status.reason).toContain('profile_backends.cursor'); + }); + + it('allows cursor image analysis only when explicitly mapped to a backend', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + cursor: 'ghcp', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'cursor', + profileType: 'cursor', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('ghcp'); + expect(status.status).toBe('mapped'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + it('uses the fallback backend for an unmapped third-party settings profile', () => { const status = resolveImageAnalysisStatus( {