Merge provider thinking controls into local main

The detached worktree carried the verified desktop Thinking fixes, while local main already had one newer memory-path commit. This merge brings the provider/runtime Thinking behavior into main without rewriting the existing main history.

Constraint: Local main is the checked-out integration branch and was ahead of the worktree base
Rejected: Rebase the detached worktree onto main | unnecessary history rewrite for a local integration request
Rejected: Fast-forward main | impossible because main already had newer local commits
Confidence: high
Scope-risk: moderate
Directive: Keep c866adc9 as the feature commit for detailed implementation context
Tested: bun test src/server/__tests__/ws-memory-events.test.ts
Tested: bun run verify before integration on the feature commit
Not-tested: Full verify after the merge commit; merge was conflict-free except automatic conversationService integration
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 19:35:24 +08:00
commit 7df77ac6e1
16 changed files with 273 additions and 28 deletions

View File

@ -220,6 +220,91 @@ describe('settingsStore desktop notification persistence', () => {
})
})
describe('settingsStore thinking persistence', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
window.localStorage.clear()
})
it('persists both enabled and disabled thinking states explicitly', async () => {
const updateUser = vi.fn().mockResolvedValue({})
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn(),
updateUser,
getPermissionMode: vi.fn(),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn(),
getCurrent: vi.fn(),
setCurrent: vi.fn(),
getEffort: vi.fn(),
setEffort: vi.fn(),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().setThinkingEnabled(false)
await useSettingsStore.getState().setThinkingEnabled(true)
expect(updateUser).toHaveBeenNthCalledWith(1, { alwaysThinkingEnabled: false })
expect(updateUser).toHaveBeenNthCalledWith(2, { alwaysThinkingEnabled: true })
expect(useSettingsStore.getState().thinkingEnabled).toBe(true)
})
it('rolls back the thinking toggle when persistence fails', async () => {
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn(),
updateUser: vi.fn().mockRejectedValue(new Error('save failed')),
getPermissionMode: vi.fn(),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn(),
getCurrent: vi.fn(),
setCurrent: vi.fn(),
getEffort: vi.fn(),
setEffort: vi.fn(),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().setThinkingEnabled(false)
expect(useSettingsStore.getState().thinkingEnabled).toBe(true)
})
})
describe('settingsStore theme persistence', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -178,7 +178,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const prev = get().thinkingEnabled
set({ thinkingEnabled: enabled })
try {
await settingsApi.updateUser({ alwaysThinkingEnabled: enabled ? undefined : false })
await settingsApi.updateUser({ alwaysThinkingEnabled: enabled })
} catch {
set({ thinkingEnabled: prev })
}

View File

@ -366,9 +366,9 @@
--color-surface-hover: var(--color-surface-container-high);
--color-surface-selected: var(--color-surface-container);
--color-memory-accent: #3B7068;
--color-memory-surface: color-mix(in srgb, var(--color-memory-accent) 7%, var(--color-surface-container-lowest));
--color-memory-border: color-mix(in srgb, var(--color-memory-accent) 26%, var(--color-border));
--color-memory-icon-bg: color-mix(in srgb, var(--color-memory-accent) 10%, var(--color-surface-container-lowest));
--color-memory-surface: #F6FAF8;
--color-memory-border: #BCCDC8;
--color-memory-icon-bg: #F3F8F7;
--color-model-option-selected-bg: var(--color-primary-fixed);
--color-model-option-selected-border: rgba(143, 72, 47, 0.2);
--color-activity-heat-0: var(--color-surface-container);
@ -567,9 +567,9 @@
--color-surface-hover: #F2F5F8;
--color-surface-selected: #E9EEF3;
--color-memory-accent: #0F766E;
--color-memory-surface: color-mix(in srgb, var(--color-memory-accent) 5%, #FFFFFF);
--color-memory-border: color-mix(in srgb, var(--color-memory-accent) 24%, var(--color-border));
--color-memory-icon-bg: color-mix(in srgb, var(--color-memory-accent) 8%, #FFFFFF);
--color-memory-surface: #F3F8F7;
--color-memory-border: #AECBC9;
--color-memory-icon-bg: #EDF7F6;
--color-model-option-selected-bg: #FFF0EA;
--color-model-option-selected-border: rgba(143, 72, 47, 0.2);
--color-activity-heat-0: #EEF2F6;
@ -745,9 +745,9 @@
--color-surface-hover: var(--color-surface-container-highest);
--color-surface-selected: var(--color-surface-container);
--color-memory-accent: #7DD3C7;
--color-memory-surface: color-mix(in srgb, var(--color-memory-accent) 9%, var(--color-surface-container-lowest));
--color-memory-border: color-mix(in srgb, var(--color-memory-accent) 26%, var(--color-border));
--color-memory-icon-bg: color-mix(in srgb, var(--color-memory-accent) 12%, var(--color-surface-container-lowest));
--color-memory-surface: #1D2423;
--color-memory-border: #334743;
--color-memory-icon-bg: #22302E;
--color-model-option-selected-bg: rgba(255, 181, 159, 0.13);
--color-model-option-selected-border: rgba(255, 181, 159, 0.34);
--color-activity-heat-0: #242322;

View File

@ -404,7 +404,7 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S
});
},
onSetMaxThinkingTokens(maxTokens) {
const enabled = maxTokens !== null;
const enabled = maxTokens !== 0;
setAppState(prev_11 => {
if (prev_11.thinkingEnabled === enabled) return prev_11;
return {

File diff suppressed because one or more lines are too long

View File

@ -242,6 +242,34 @@ describe('ConversationService', () => {
])
})
it('should send thinking token controls to active CLI sessions', () => {
const svc = new ConversationService() as any
const sent: string[] = []
svc.sessions.set('session-thinking-control', {
sdkSocket: { send: (data: string) => sent.push(data) },
pendingOutbound: [],
})
expect(svc.setMaxThinkingTokens('session-thinking-control', 0)).toBe(true)
expect(svc.setMaxThinkingTokens('session-thinking-control', null)).toBe(true)
expect(svc.setMaxThinkingTokensForActiveSessions(0)).toBe(1)
expect(sent.map((line) => JSON.parse(line).request)).toEqual([
{
subtype: 'set_max_thinking_tokens',
max_thinking_tokens: 0,
},
{
subtype: 'set_max_thinking_tokens',
max_thinking_tokens: null,
},
{
subtype: 'set_max_thinking_tokens',
max_thinking_tokens: 0,
},
])
})
it('should return false when sending interrupt to non-existent session', () => {
const svc = new ConversationService()
const result = svc.sendInterrupt('no-such-session')

View File

@ -2,11 +2,12 @@
* Unit tests for Settings, Models, and Status APIs
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'
import { describe, it, expect, beforeAll, beforeEach, afterEach, spyOn } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { SettingsService } from '../services/settingsService.js'
import { conversationService } from '../services/conversationService.js'
import { handleSettingsApi } from '../api/settings.js'
import { handleModelsApi } from '../api/models.js'
import { handleStatusApi, resetUsage, addUsage } from '../api/status.js'
@ -394,6 +395,28 @@ describe('Settings API', () => {
expect(body2.model).toBe('claude-opus-4-7')
})
it('PUT /api/settings/user should sync thinking changes to active CLI sessions', async () => {
const syncSpy = spyOn(conversationService, 'setMaxThinkingTokensForActiveSessions')
.mockImplementation(() => 0)
try {
const disabled = makeRequest('PUT', '/api/settings/user', {
alwaysThinkingEnabled: false,
})
expect((await handleSettingsApi(disabled.req, disabled.url, disabled.segments)).status).toBe(200)
const enabled = makeRequest('PUT', '/api/settings/user', {
alwaysThinkingEnabled: true,
})
expect((await handleSettingsApi(enabled.req, enabled.url, enabled.segments)).status).toBe(200)
expect(syncSpy).toHaveBeenNthCalledWith(1, 0)
expect(syncSpy).toHaveBeenNthCalledWith(2, null)
} finally {
syncSpy.mockRestore()
}
})
it('GET /api/settings/cli-launcher should expose bundled launcher status', async () => {
if (process.platform === 'win32') return

View File

@ -70,7 +70,7 @@ describe('titleService', () => {
}
})
test('does not force disabled thinking for DeepSeek title generation', async () => {
test('sends disabled thinking for DeepSeek title generation when desktop thinking is off', async () => {
let requestBody: Record<string, unknown> | null = null
const server = Bun.serve({
hostname: '127.0.0.1',
@ -114,7 +114,7 @@ describe('titleService', () => {
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBody?.thinking).toBeUndefined()
expect(requestBody?.thinking).toEqual({ type: 'disabled' })
} finally {
server.stop(true)
}

View File

@ -34,3 +34,54 @@ describe('WebSocket memory events', () => {
])
})
})
describe('WebSocket stream event translation', () => {
it('keeps DeepSeek-style thinking blocks in thinking state until text starts', () => {
const sessionId = `deepseek-thinking-${crypto.randomUUID()}`
expect(translateCliMessage({
type: 'stream_event',
event: { type: 'message_start' },
}, sessionId)).toEqual([
{ type: 'status', state: 'thinking' },
])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_start',
index: 0,
content_block: { type: 'thinking', thinking: '' },
},
}, sessionId)).toEqual([
{ type: 'status', state: 'thinking', verb: 'Thinking' },
])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'Let me think' },
},
}, sessionId)).toEqual([
{ type: 'thinking', text: 'Let me think' },
])
expect(translateCliMessage({
type: 'stream_event',
event: { type: 'content_block_stop', index: 0 },
}, sessionId)).toEqual([])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_start',
index: 1,
content_block: { type: 'text', text: '' },
},
}, sessionId)).toEqual([
{ type: 'content_start', blockType: 'text' },
])
})
})

View File

@ -13,6 +13,7 @@
import { SettingsService } from '../services/settingsService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { ensureDesktopCliLauncherInstalled } from '../services/desktopCliLauncherService.js'
import { conversationService } from '../services/conversationService.js'
const settingsService = new SettingsService()
@ -70,6 +71,7 @@ async function handleUserSettings(req: Request): Promise<Response> {
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
await settingsService.updateUserSettings(body)
syncThinkingSettingToActiveSessions(body)
return Response.json({ ok: true })
}
@ -124,3 +126,16 @@ async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
function methodNotAllowed(method: string): ApiError {
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
}
function syncThinkingSettingToActiveSessions(settings: Record<string, unknown>): void {
if (
!Object.prototype.hasOwnProperty.call(settings, 'alwaysThinkingEnabled') ||
typeof settings.alwaysThinkingEnabled !== 'boolean'
) {
return
}
conversationService.setMaxThinkingTokensForActiveSessions(
settings.alwaysThinkingEnabled ? null : 0,
)
}

View File

@ -440,6 +440,27 @@ export class ConversationService {
})
}
setMaxThinkingTokens(sessionId: string, maxThinkingTokens: number | null): boolean {
return this.sendSdkMessage(sessionId, {
type: 'control_request',
request_id: crypto.randomUUID(),
request: {
subtype: 'set_max_thinking_tokens',
max_thinking_tokens: maxThinkingTokens,
},
})
}
setMaxThinkingTokensForActiveSessions(maxThinkingTokens: number | null): number {
let sent = 0
for (const sessionId of this.getActiveSessions()) {
if (this.setMaxThinkingTokens(sessionId, maxThinkingTokens)) {
sent += 1
}
}
return sent
}
sendInterrupt(sessionId: string): boolean {
return this.sendSdkMessage(sessionId, {
type: 'control_request',

View File

@ -9,8 +9,6 @@
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'
import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js'
const TITLE_MAX_LEN = 50
@ -73,7 +71,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 shouldDisableThinking = await shouldDisableThinkingForTitle()
const response = await fetch(url, {
method: 'POST',
@ -177,12 +175,9 @@ function looksLikeStructuredTitleFragment(text: string): boolean {
)
}
async function shouldDisableThinkingForTitle(presetId: string): Promise<boolean> {
async function shouldDisableThinkingForTitle(): Promise<boolean> {
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)
return settings.alwaysThinkingEnabled === false
}
/**

View File

@ -692,7 +692,7 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: s
*/
type SessionStreamState = {
hasReceivedStreamEvents: boolean
activeBlockTypes: Map<number, 'text' | 'tool_use'>
activeBlockTypes: Map<number, 'text' | 'tool_use' | 'thinking'>
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string }>
/** Tool blocks whose input JSON failed to parse in content_block_stop.
* The assistant message carries the complete input defer to that. */
@ -990,7 +990,7 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
switch (event.type) {
case 'message_start': {
return [{ type: 'status', state: 'streaming' }]
return [{ type: 'status', state: 'thinking' }]
}
case 'content_block_start': {
@ -998,9 +998,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
if (!contentBlock) return []
const index = event.index ?? 0
streamState.activeBlockTypes.set(index, contentBlock.type === 'tool_use' ? 'tool_use' : 'text')
if (contentBlock.type === 'tool_use') {
streamState.activeBlockTypes.set(index, 'tool_use')
// Track tool info so content_block_stop can emit complete data
streamState.activeToolBlocks.set(index, {
toolName: contentBlock.name || '',
@ -1018,6 +1018,13 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
: undefined,
}]
}
if (contentBlock.type === 'thinking' || contentBlock.type === 'redacted_thinking') {
streamState.activeBlockTypes.set(index, 'thinking')
return [{ type: 'status', state: 'thinking', verb: 'Thinking' }]
}
streamState.activeBlockTypes.set(index, 'text')
return [{ type: 'content_start', blockType: 'text' }]
}

View File

@ -1613,7 +1613,7 @@ async function* queryModel(
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING)
const modelCanThink = modelSupportsThinking(options.model)
const sendsExplicitDisabledThinking =
!(hasThinking && modelCanThink) && shouldSendExplicitDisabledThinking()
!hasThinking && (modelCanThink || shouldSendExplicitDisabledThinking())
const outputConfig: BetaOutputConfig = {
...((extraBodyParams.output_config as BetaOutputConfig) ?? {}),

View File

@ -90,6 +90,16 @@ describe('provider-aware thinking support', () => {
expect(shouldSendExplicitDisabledThinking()).toBe(false)
})
test('MiniMax preset models declare thinking support without effort passthrough', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.minimaxi.com/anthropic'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'MiniMax-M2.7'
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES
clearCapabilityCache()
expect(modelSupportsThinking('MiniMax-M2.7')).toBe(true)
expect(modelSupportsAdaptiveThinking('MiniMax-M2.7')).toBe(false)
})
test('side queries inherit explicit disabled thinking for opted-in providers', () => {
delete process.env.CC_HAHA_SEND_DISABLED_THINKING
expect(resolveSideQueryThinkingConfig(undefined, 1024)).toBeUndefined()

View File

@ -109,10 +109,18 @@ export function modelSupportsThinking(model: string): boolean {
) {
return !canonical.includes('claude-3-')
}
if (isMiniMaxAnthropicEndpoint() && canonical.includes('minimax')) {
return true
}
// 3P (Bedrock/Vertex): only Opus 4+ and Sonnet 4+
return canonical.includes('sonnet-4') || canonical.includes('opus-4')
}
function isMiniMaxAnthropicEndpoint(): boolean {
const baseUrl = process.env.ANTHROPIC_BASE_URL?.toLowerCase() ?? ''
return baseUrl.includes('minimax') || baseUrl.includes('minimaxi')
}
// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports adaptive thinking.
export function modelSupportsAdaptiveThinking(model: string): boolean {
const supported3P = get3PModelCapabilityOverride(model, 'adaptive_thinking')