From 9ecc178c7ae4c8257081871fa7efafdde6ae4c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 1 May 2026 10:13:10 +0800 Subject: [PATCH] fix: prevent third-party thinking mode mismatches Desktop users can now disable thinking for new sessions, and Anthropic-compatible providers that opt in receive explicit disabled-thinking requests across main turns, side queries, and AI title generation. Constraint: DeepSeek/Kimi/GLM Anthropic-compatible endpoints need a non-thinking path without scattering provider-specific logic through the CLI. Rejected: Per-model if/else branches | centralized provider preset env keeps the native CLI surface smaller and easier to audit. Confidence: high Scope-risk: moderate Tested: bun test src/utils/__tests__/thinking.test.ts src/server/__tests__/title-service.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run test -- generalSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: agent-browser DeepSeek desktop E2E with transparent proxy captured main and title requests with thinking.type=disabled --- .../src/__tests__/generalSettings.test.tsx | 16 ++++ desktop/src/i18n/locales/en.ts | 4 + desktop/src/i18n/locales/zh.ts | 4 + desktop/src/pages/Settings.tsx | 24 ++++++ desktop/src/stores/settingsStore.ts | 14 ++++ desktop/src/types/settings.ts | 1 + src/server/__tests__/conversations.test.ts | 63 +++++++++++++-- src/server/__tests__/provider-presets.test.ts | 3 + src/server/__tests__/title-service.test.ts | 79 +++++++++++++++++++ src/server/config/providerPresets.json | 15 +++- src/server/services/conversationService.ts | 7 ++ src/server/services/titleService.ts | 13 +++ src/server/ws/handler.ts | 11 +++ src/services/api/claude.ts | 5 ++ src/utils/__tests__/thinking.test.ts | 28 ++++++- src/utils/sideQuery.ts | 30 ++++--- src/utils/thinking.ts | 5 ++ 17 files changed, 303 insertions(+), 19 deletions(-) create mode 100644 src/server/__tests__/title-service.test.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 88269f08..f6302964 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -106,7 +106,11 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ locale: 'en', + thinkingEnabled: true, skipWebFetchPreflight: true, + setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { + useSettingsStore.setState({ thinkingEnabled: enabled }) + }), setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ skipWebFetchPreflight: enabled }) }), @@ -150,6 +154,18 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false) }) + it('lets the user disable thinking mode for new sessions', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const toggle = screen.getByLabelText('Enable thinking mode') + expect(toggle).toBeChecked() + fireEvent.click(toggle) + + expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false) + }) + it('keeps extension tabs available alongside the terminal tab', () => { render() diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ca59f1e5..ef27c134 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -482,6 +482,10 @@ export const en = { 'settings.general.effort.medium': 'Medium', 'settings.general.effort.high': 'High', 'settings.general.effort.max': 'Max', + 'settings.general.thinkingTitle': 'Thinking Mode', + 'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.', + 'settings.general.thinkingEnabled': 'Enable thinking mode', + 'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 8350dfbb..87fc06e1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -484,6 +484,10 @@ export const zh: Record = { 'settings.general.effort.medium': '中', 'settings.general.effort.high': '高', 'settings.general.effort.max': '最大', + 'settings.general.thinkingTitle': '思考模式', + 'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后,DeepSeek 等兼容供应商会收到显式非思考模式参数。', + 'settings.general.thinkingEnabled': '启用思考模式', + 'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 5ff8c76a..5aaf1a4f 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -872,6 +872,8 @@ function GeneralSettings() { const { effortLevel, setEffort, + thinkingEnabled, + setThinkingEnabled, locale, setLocale, theme, @@ -957,6 +959,28 @@ function GeneralSettings() { ))} +
+

{t('settings.general.thinkingTitle')}

+

{t('settings.general.thinkingDescription')}

+ +
+

{t('settings.general.webFetchPreflightTitle')}

{t('settings.general.webFetchPreflightDescription')}

diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 3613287d..ca038abe 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -19,6 +19,7 @@ type SettingsStore = { permissionMode: PermissionMode currentModel: ModelInfo | null effortLevel: EffortLevel + thinkingEnabled: boolean availableModels: ModelInfo[] activeProviderName: string | null locale: Locale @@ -31,6 +32,7 @@ type SettingsStore = { setPermissionMode: (mode: PermissionMode) => Promise setModel: (modelId: string) => Promise setEffort: (level: EffortLevel) => Promise + setThinkingEnabled: (enabled: boolean) => Promise setLocale: (locale: Locale) => void setTheme: (theme: ThemeMode) => Promise setSkipWebFetchPreflight: (enabled: boolean) => Promise @@ -40,6 +42,7 @@ export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, effortLevel: 'medium', + thinkingEnabled: true, availableModels: [], activeProviderName: null, locale: getStoredLocale(), @@ -66,6 +69,7 @@ export const useSettingsStore = create((set, get) => ({ activeProviderName: modelsRes.provider?.name ?? null, currentModel: model, effortLevel: level, + thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, theme, skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, isLoading: false, @@ -105,6 +109,16 @@ export const useSettingsStore = create((set, get) => ({ } }, + setThinkingEnabled: async (enabled) => { + const prev = get().thinkingEnabled + set({ thinkingEnabled: enabled }) + try { + await settingsApi.updateUser({ alwaysThinkingEnabled: enabled ? undefined : false }) + } catch { + set({ thinkingEnabled: prev }) + } + }, + setLocale: (locale) => { set({ locale }) try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ } diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 9b246eea..66352ef3 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -16,6 +16,7 @@ export type UserSettings = { model?: string modelContext?: string effort?: EffortLevel + alwaysThinkingEnabled?: boolean permissionMode?: PermissionMode theme?: ThemeMode skipWebFetchPreflight?: boolean diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index af22e877..9632588d 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -138,6 +138,22 @@ describe('ConversationService', () => { ]) }) + it('should pass disabled thinking to the CLI runtime args', () => { + const svc = new ConversationService() + expect((svc as any).getRuntimeArgs({ + model: 'deepseek-v4-pro', + effort: 'medium', + thinking: 'disabled', + })).toEqual([ + '--model', + 'deepseek-v4-pro', + '--effort', + 'medium', + '--thinking', + 'disabled', + ]) + }) + it('should return false when sending interrupt to non-existent session', () => { const svc = new ConversationService() const result = svc.sendInterrupt('no-such-session') @@ -632,6 +648,41 @@ describe('WebSocket Chat Integration', () => { expect(statusMsgs[0].state).toBe('thinking') }) + it('should start desktop sessions with disabled thinking when configured', async () => { + const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}` + const originalStartSession = conversationService.startSession.bind(conversationService) + const startOptions: Array<{ thinking?: string; model?: string }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, + ) { + if (sid === sessionId) { + startOptions.push({ thinking: options?.thinking, model: options?.model }) + } + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + try { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ alwaysThinkingEnabled: false }, null, 2), + 'utf-8', + ) + + const messages = await runTurn(sessionId, 'Hello without thinking') + + expect(messages.some((m) => m.type === 'message_complete')).toBe(true) + expect(startOptions).toEqual([{ thinking: 'disabled', model: undefined }]) + } finally { + conversationService.startSession = originalStartSession as typeof conversationService.startSession + conversationService.stopSession(sessionId) + await fs.writeFile(path.join(tmpDir, 'settings.json'), '{}\n', 'utf-8') + } + }) + it('should continue chat when SDK init arrives only after the first user turn', async () => { const messages = await withMockInitMode('on_first_user', () => runTurn('chat-test-lazy-init', 'Hello after lazy init'), @@ -901,7 +952,7 @@ describe('WebSocket Chat Integration', () => { sid: string, workDir: string, sdkUrl: string, - options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { startCalls.push({ sessionId: sid }) return originalStartSession(sid, workDir, sdkUrl, options) @@ -1037,7 +1088,7 @@ describe('WebSocket Chat Integration', () => { sid: string, workDir: string, sdkUrl: string, - options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { startCalls.push({ sessionId: sid, options }) return originalStartSession(sid, workDir, sdkUrl, options) @@ -1102,7 +1153,7 @@ describe('WebSocket Chat Integration', () => { sid: string, workDir: string, sdkUrl: string, - options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { startCalls.push({ sessionId: sid, options }) if (startCalls.length === 1) { @@ -1205,7 +1256,7 @@ describe('WebSocket Chat Integration', () => { sid: string, workDir: string, sdkUrl: string, - options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { startCalls.push({ sessionId: sid, options }) return originalStartSession(sid, workDir, sdkUrl, options) @@ -1386,7 +1437,7 @@ describe('WebSocket Chat Integration', () => { sid: string, workDir: string, sdkUrl: string, - options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { startCalls.push({ sessionId: sid, options }) return originalStartSession(sid, workDir, sdkUrl, options) @@ -1532,7 +1583,7 @@ describe('WebSocket Chat Integration', () => { sid: string, workDir: string, sdkUrl: string, - options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { startCalls.push({ sessionId: sid, options }) return originalStartSession(sid, workDir, sdkUrl, options) diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts index 360da462..ed8fff6f 100644 --- a/src/server/__tests__/provider-presets.test.ts +++ b/src/server/__tests__/provider-presets.test.ts @@ -80,12 +80,14 @@ describe('provider presets API', () => { expect(deepseek?.defaultModels.haiku).toBe('deepseek-v4-flash') expect(deepseek?.defaultModels.sonnet).toBe('deepseek-v4-pro') expect(deepseek?.defaultModels.opus).toBe('deepseek-v4-pro') + expect(deepseek?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1') expect(zhipu?.defaultModels.main).toBe('glm-5.1') expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air') expect(zhipu?.defaultModels.sonnet).toBe('glm-5-turbo') expect(zhipu?.defaultModels.opus).toBe('glm-5.1') expect(kimi?.baseUrl).toBe('https://api.kimi.com/coding') expect(kimi?.defaultModels.main).toBe('kimi-k2.6') + expect(kimi?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1') expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7') expect(jiekouai?.baseUrl).toBe('https://api.jiekou.ai/anthropic') expect(jiekouai?.defaultModels.main).toBe('claude-sonnet-4-6') @@ -118,6 +120,7 @@ describe('provider presets API', () => { expect(deepseek?.apiKeyUrl).toBe('https://platform.deepseek.com/api_keys') expect(zhipu?.apiKeyUrl).toBe('https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D') expect(zhipu?.promoText).toContain('cc-haha') + expect(zhipu?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1') expect(kimi?.apiKeyUrl).toBe('https://platform.kimi.com/console/api-keys') expect(minimax?.apiKeyUrl).toBe('https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link') expect(jiekouai?.apiKeyUrl).toBe('https://jiekou.ai/referral?invited_code=OBNU3K') diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts new file mode 100644 index 00000000..f01d2670 --- /dev/null +++ b/src/server/__tests__/title-service.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { generateTitle } from '../services/titleService.js' + +describe('titleService', () => { + let tmpDir: string + let originalConfigDir: string | undefined + + beforeEach(async () => { + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'title-service-test-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir + }) + + afterEach(async () => { + restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test('sends disabled thinking for opted-in providers when desktop thinking is off', async () => { + let requestBody: Record | null = null + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + requestBody = await req.json() as Record + return Response.json({ + content: [{ type: 'text', text: '{"title":"Trace ok"}' }], + }) + }, + }) + + try { + const providerId = 'deepseek-test' + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ alwaysThinkingEnabled: false }, null, 2), + ) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ + activeId: providerId, + providers: [ + { + id: providerId, + presetId: 'deepseek', + name: 'DeepSeek', + apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${server.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'deepseek-v4-pro', + haiku: 'deepseek-v4-pro', + sonnet: 'deepseek-v4-pro', + opus: 'deepseek-v4-pro', + }, + }, + ], + }, null, 2), + ) + + await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok') + expect(requestBody?.thinking).toEqual({ type: 'disabled' }) + } finally { + server.stop(true) + } + }) +}) + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index f47c2395..d5dfe219 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -26,7 +26,10 @@ }, "needsApiKey": true, "websiteUrl": "https://platform.deepseek.com", - "apiKeyUrl": "https://platform.deepseek.com/api_keys" + "apiKeyUrl": "https://platform.deepseek.com/api_keys", + "defaultEnv": { + "CC_HAHA_SEND_DISABLED_THINKING": "1" + } }, { "id": "zhipuglm", @@ -42,7 +45,10 @@ "needsApiKey": true, "websiteUrl": "https://open.bigmodel.cn", "apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D", - "promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。" + "promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。", + "defaultEnv": { + "CC_HAHA_SEND_DISABLED_THINKING": "1" + } }, { "id": "kimi", @@ -57,7 +63,10 @@ }, "needsApiKey": true, "websiteUrl": "https://platform.moonshot.cn", - "apiKeyUrl": "https://platform.kimi.com/console/api-keys" + "apiKeyUrl": "https://platform.kimi.com/console/api-keys", + "defaultEnv": { + "CC_HAHA_SEND_DISABLED_THINKING": "1" + } }, { "id": "minimax", diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index ff5f6155..4c55d4e8 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -49,6 +49,7 @@ type SessionStartOptions = { permissionMode?: string model?: string effort?: string + thinking?: 'enabled' | 'adaptive' | 'disabled' providerId?: string | null } @@ -589,6 +590,10 @@ export class ConversationService { args.push('--effort', options.effort) } + if (options?.thinking) { + args.push('--thinking', options.thinking) + } + return args } @@ -615,6 +620,7 @@ export class ConversationService { 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'CC_HAHA_SEND_DISABLED_THINKING', ] as const const cleanEnv = { ...process.env } @@ -738,6 +744,7 @@ export class ConversationService { 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'CC_HAHA_SEND_DISABLED_THINKING', ].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0) } catch { return false diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index 5a99f356..fd319ae4 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -7,7 +7,10 @@ */ import { ProviderService } from './providerService.js' +import { SettingsService } from './settingsService.js' import { sessionService } from './sessionService.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' +import { isEnvTruthy } from '../../utils/envUtils.js' const TITLE_MAX_LEN = 50 @@ -69,6 +72,7 @@ export async function generateTitle( const model = resolvedProvider.models.haiku || resolvedProvider.models.main const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` + const shouldDisableThinking = await shouldDisableThinkingForTitle(resolvedProvider.presetId) const response = await fetch(url, { method: 'POST', @@ -82,6 +86,7 @@ export async function generateTitle( max_tokens: 100, system: TITLE_SYSTEM_PROMPT, messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], + ...(shouldDisableThinking && { thinking: { type: 'disabled' } }), }), signal: AbortSignal.timeout(15_000), }) @@ -108,6 +113,14 @@ export async function generateTitle( } } +async function shouldDisableThinkingForTitle(presetId: string): Promise { + const settings = await new SettingsService().getUserSettings() + if (settings.alwaysThinkingEnabled !== false) return false + + const presetEnv = PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv + return isEnvTruthy(presetEnv?.CC_HAHA_SEND_DISABLED_THINKING) +} + /** * Persist an AI-generated title to the session's JSONL file. */ diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 1c5ce276..5656e809 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1236,6 +1236,7 @@ type RuntimeSettings = { permissionMode?: string model?: string effort?: string + thinking?: 'disabled' providerId?: string | null } @@ -1259,11 +1260,13 @@ async function getRuntimeSettings(sessionId?: string): Promise typeof userSettings.effort === 'string' && userSettings.effort.trim() ? userSettings.effort : undefined + const thinking = resolveDesktopThinkingMode(userSettings) return { permissionMode: await settingsService.getPermissionMode().catch(() => undefined), model: runtimeOverride.modelId, effort, + thinking, providerId: runtimeOverride.providerId, } } @@ -1294,6 +1297,7 @@ async function getDefaultRuntimeSettings(): Promise { typeof userSettings.effort === 'string' && userSettings.effort.trim() ? userSettings.effort : undefined + const thinking = resolveDesktopThinkingMode(userSettings) let model: string | undefined if (resolvedActiveId) { @@ -1320,10 +1324,17 @@ async function getDefaultRuntimeSettings(): Promise { permissionMode: await settingsService.getPermissionMode().catch(() => undefined), model, effort, + thinking, providerId: resolvedActiveId, } } +function resolveDesktopThinkingMode( + settings: Record, +): 'disabled' | undefined { + return settings.alwaysThinkingEnabled === false ? 'disabled' : undefined +} + async function buildSessionStartupDiagnosticMessage( sessionId: string, cause: string, diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 89a6e661..3e567955 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -181,6 +181,7 @@ import { endQueryProfile, queryCheckpoint } from 'src/utils/queryProfiler.js' import { modelSupportsAdaptiveThinking, modelSupportsThinking, + shouldSendExplicitDisabledThinking, type ThinkingConfig, } from 'src/utils/thinking.js' import { @@ -1627,6 +1628,10 @@ async function* queryModel( type: 'enabled', } satisfies BetaMessageStreamParams['thinking'] } + } else if (shouldSendExplicitDisabledThinking()) { + thinking = { + type: 'disabled', + } as unknown as BetaMessageStreamParams['thinking'] } // Get API context management strategies if enabled diff --git a/src/utils/__tests__/thinking.test.ts b/src/utils/__tests__/thinking.test.ts index 949504f6..397a6760 100644 --- a/src/utils/__tests__/thinking.test.ts +++ b/src/utils/__tests__/thinking.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { get3PModelCapabilityOverride } from '../model/modelSupportOverrides.js' -import { modelSupportsAdaptiveThinking, modelSupportsThinking } from '../thinking.js' +import { resolveSideQueryThinkingConfig } from '../sideQuery.js' +import { + modelSupportsAdaptiveThinking, + modelSupportsThinking, + shouldSendExplicitDisabledThinking, +} from '../thinking.js' describe('provider-aware thinking support', () => { let originalBaseUrl: string | undefined @@ -9,6 +14,7 @@ describe('provider-aware thinking support', () => { let originalBedrock: string | undefined let originalVertex: string | undefined let originalFoundry: string | undefined + let originalExplicitDisabledThinking: string | undefined beforeEach(() => { originalBaseUrl = process.env.ANTHROPIC_BASE_URL @@ -17,6 +23,7 @@ describe('provider-aware thinking support', () => { originalBedrock = process.env.CLAUDE_CODE_USE_BEDROCK originalVertex = process.env.CLAUDE_CODE_USE_VERTEX originalFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY + originalExplicitDisabledThinking = process.env.CC_HAHA_SEND_DISABLED_THINKING delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX @@ -30,6 +37,7 @@ describe('provider-aware thinking support', () => { restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalBedrock) restoreEnv('CLAUDE_CODE_USE_VERTEX', originalVertex) restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalFoundry) + restoreEnv('CC_HAHA_SEND_DISABLED_THINKING', originalExplicitDisabledThinking) clearCapabilityCache() }) @@ -60,6 +68,24 @@ describe('provider-aware thinking support', () => { expect(modelSupportsThinking('claude-sonnet-4-6')).toBe(true) expect(modelSupportsAdaptiveThinking('claude-sonnet-4-6')).toBe(true) }) + + test('only sends explicit disabled thinking when the provider opts in', () => { + delete process.env.CC_HAHA_SEND_DISABLED_THINKING + expect(shouldSendExplicitDisabledThinking()).toBe(false) + + process.env.CC_HAHA_SEND_DISABLED_THINKING = '1' + expect(shouldSendExplicitDisabledThinking()).toBe(true) + }) + + test('side queries inherit explicit disabled thinking for opted-in providers', () => { + delete process.env.CC_HAHA_SEND_DISABLED_THINKING + expect(resolveSideQueryThinkingConfig(undefined, 1024)).toBeUndefined() + + process.env.CC_HAHA_SEND_DISABLED_THINKING = '1' + expect(resolveSideQueryThinkingConfig(undefined, 1024)).toEqual({ type: 'disabled' }) + expect(resolveSideQueryThinkingConfig(false, 1024)).toEqual({ type: 'disabled' }) + expect(resolveSideQueryThinkingConfig(256, 1024)).toEqual({ type: 'enabled', budget_tokens: 256 }) + }) }) function restoreEnv(key: string, value: string | undefined) { diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 4e6d4d73..e01e5b26 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -17,6 +17,7 @@ import { getAnthropicClient } from '../services/api/client.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' import { computeFingerprint } from './fingerprint.js' import { normalizeModelStringForAPI } from './model/model.js' +import { shouldSendExplicitDisabledThinking } from './thinking.js' type MessageParam = Anthropic.MessageParam type TextBlockParam = Anthropic.TextBlockParam @@ -166,15 +167,7 @@ export async function sideQuery(opts: SideQueryOptions): Promise { : []), ].filter((block): block is TextBlockParam => block !== null) - let thinkingConfig: BetaThinkingConfigParam | undefined - if (thinking === false) { - thinkingConfig = { type: 'disabled' } - } else if (thinking !== undefined) { - thinkingConfig = { - type: 'enabled', - budget_tokens: Math.min(thinking, max_tokens - 1), - } - } + const thinkingConfig = resolveSideQueryThinkingConfig(thinking, max_tokens) const normalizedModel = normalizeModelStringForAPI(model) const start = Date.now() @@ -220,3 +213,22 @@ export async function sideQuery(opts: SideQueryOptions): Promise { return response } + +export function resolveSideQueryThinkingConfig( + thinking: SideQueryOptions['thinking'], + maxTokens: number, +): BetaThinkingConfigParam | undefined { + if ( + thinking === false || + (thinking === undefined && shouldSendExplicitDisabledThinking()) + ) { + return { type: 'disabled' } + } + if (thinking !== undefined) { + return { + type: 'enabled', + budget_tokens: Math.min(thinking, maxTokens - 1), + } + } + return undefined +} diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index 506c3692..b60ce573 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -6,6 +6,7 @@ import { getCanonicalName } from './model/model.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js' import { getSettingsWithErrors } from './settings/settings.js' +import { isEnvTruthy } from './envUtils.js' export type ThinkingConfig = | { type: 'adaptive' } @@ -170,3 +171,7 @@ export function shouldEnableThinkingByDefault(): boolean { // Enable thinking by default unless explicitly disabled. return true } + +export function shouldSendExplicitDisabledThinking(): boolean { + return isEnvTruthy(process.env.CC_HAHA_SEND_DISABLED_THINKING) +}