Keep long desktop sessions responsive while honoring thinking controls

Virtualized chat rendering keeps inactive long-running sessions cheap without disconnecting their CLI process, while DeepSeek now relies on the shared Thinking setting instead of a provider-specific disabled-thinking override. Existing legacy DeepSeek managed env is normalized so old local settings do not keep suppressing reasoning output after upgrade.

Constraint: Multiple desktop tabs must keep live sessions running and remain quick to switch without reconnecting.
Rejected: Pause or disconnect hidden sessions | would delay tab switching and interrupt streaming/tool state visibility.
Rejected: Keep DeepSeek disabled-thinking preset | conflicts with the General Settings Thinking control.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce provider-specific disabled-thinking defaults for DeepSeek without testing both General Settings toggle states.
Tested: bun test src/server/__tests__/conversations.test.ts -t "global Thinking setting control DeepSeek"
Tested: bun test src/server/__tests__/persistence-upgrade.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/title-service.test.ts src/utils/__tests__/thinking.test.ts src/utils/__tests__/providerManagedEnvCompat.test.ts
Tested: cd desktop && bun run test -- MessageList.test.tsx ContextUsageIndicator.test.tsx
Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 22:42:01 +08:00
parent cb2978b222
commit 611f09a1a7
15 changed files with 842 additions and 38 deletions

View File

@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const { sessionsApiMock } = vi.hoisted(() => ({
sessionsApiMock: {
getInspection: vi.fn(),
},
}))
vi.mock('../../api/sessions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/sessions')>()
return {
...actual,
sessionsApi: {
...actual.sessionsApi,
getInspection: sessionsApiMock.getInspection,
},
}
})
import { ContextUsageIndicator } from './ContextUsageIndicator'
import { useSettingsStore } from '../../stores/settingsStore'
const baseInspection = {
active: true,
status: {
sessionId: 'session-1',
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions' as const,
model: 'kimi-k2.6',
},
context: {
categories: [{ name: 'Messages', tokens: 42_000, color: '#2D628F' }],
totalTokens: 42_000,
maxTokens: 200_000,
rawMaxTokens: 200_000,
percentage: 21,
gridRows: [],
model: 'kimi-k2.6',
memoryFiles: [],
mcpTools: [],
agents: [],
},
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('ContextUsageIndicator request behavior', () => {
const originalVisibility = document.visibilityState
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
})
afterEach(() => {
cleanup()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: originalVisibility,
})
})
it('does not auto-fetch context while the document is hidden', async () => {
sessionsApiMock.getInspection.mockResolvedValue(baseInspection)
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
})
render(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={1}
/>,
)
await act(async () => {
await Promise.resolve()
})
expect(sessionsApiMock.getInspection).not.toHaveBeenCalled()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'))
await Promise.resolve()
})
await waitFor(() => {
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1)
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledWith('session-1', {
includeContext: true,
contextOnly: true,
timeout: 20_000,
})
})
it('reuses the in-flight auto inspection during session-load rerenders', async () => {
sessionsApiMock.getInspection.mockImplementation(() => new Promise(() => {}))
const { rerender } = render(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
/>,
)
await act(async () => {
await Promise.resolve()
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1)
rerender(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={1}
/>,
)
await act(async () => {
await Promise.resolve()
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1)
})
it('starts a new auto inspection when the runtime identity changes', async () => {
sessionsApiMock.getInspection.mockImplementation(() => new Promise(() => {}))
const { rerender } = render(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
runtimeSelectionKey="deepseek:deepseek-chat"
/>,
)
await act(async () => {
await Promise.resolve()
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1)
rerender(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
runtimeSelectionKey="deepseek:deepseek-reasoner"
/>,
)
await act(async () => {
await Promise.resolve()
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(2)
})
it('ignores a stale inspection response after the runtime identity changes', async () => {
const first = deferred<typeof baseInspection>()
sessionsApiMock.getInspection
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({
...baseInspection,
context: { ...baseInspection.context, percentage: 21 },
})
const { rerender } = render(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
runtimeSelectionKey="deepseek:deepseek-chat"
/>,
)
await act(async () => {
await Promise.resolve()
})
rerender(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
runtimeSelectionKey="deepseek:deepseek-reasoner"
/>,
)
await waitFor(() => {
expect(screen.getAllByText('21%').length).toBeGreaterThan(0)
})
await act(async () => {
first.resolve({
...baseInspection,
context: { ...baseInspection.context, percentage: 90 },
})
await first.promise
})
expect(screen.getAllByText('21%').length).toBeGreaterThan(0)
expect(screen.queryByText('90%')).not.toBeInTheDocument()
})
it('ignores a stale inspection response when identity changes while hidden', async () => {
const first = deferred<typeof baseInspection>()
sessionsApiMock.getInspection.mockReturnValueOnce(first.promise)
const { rerender } = render(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
runtimeSelectionKey="deepseek:deepseek-chat"
/>,
)
await act(async () => {
await Promise.resolve()
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1)
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
})
rerender(
<ContextUsageIndicator
sessionId="session-1"
chatState="idle"
messageCount={0}
runtimeSelectionKey="deepseek:deepseek-reasoner"
/>,
)
await act(async () => {
first.resolve({
...baseInspection,
context: { ...baseInspection.context, percentage: 90 },
})
await first.promise
})
expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1)
expect(screen.queryByText('90%')).not.toBeInTheDocument()
})
})

View File

@ -16,6 +16,7 @@ type Props = {
const ACTIVE_REFRESH_MS = 30_000
const CONTEXT_REQUEST_TIMEOUT_MS = 20_000
const AUTO_REFRESH_MIN_INTERVAL_MS = 10_000
function formatNumber(value: number | undefined) {
return new Intl.NumberFormat().format(value ?? 0)
@ -50,6 +51,10 @@ function isCliNotRunningError(error: string | null) {
return error?.toLowerCase().includes('cli session is not running') ?? false
}
function isDocumentVisible() {
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
}
function shouldFetchContext(sessionId: string | undefined, draft: boolean) {
return Boolean(sessionId) && !draft
}
@ -73,64 +78,104 @@ export function ContextUsageIndicator({
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false)
const requestSeq = useRef(0)
const contextIdentityRef = useRef('')
const inFlightRequestRef = useRef<Promise<void> | null>(null)
const inFlightIdentityRef = useRef<string | null>(null)
const lastAutoRefreshAtRef = useRef(0)
const refresh = useCallback(async () => {
const refresh = useCallback(async (mode: 'auto' | 'manual' = 'manual') => {
if (!sessionId || draft) {
setLoading(false)
return
}
if (mode === 'auto' && !isDocumentVisible()) {
setLoading(false)
return
}
if (mode === 'auto' && Date.now() - lastAutoRefreshAtRef.current < AUTO_REFRESH_MIN_INTERVAL_MS) {
return inFlightRequestRef.current ?? undefined
}
if (typeof sessionsApi.getInspection !== 'function') {
setLoading(false)
return
}
const activeSessionId = sessionId
const activeContextIdentity = `${activeSessionId}:${runtimeSelectionKey}`
if (inFlightRequestRef.current && inFlightIdentityRef.current === activeContextIdentity) {
return inFlightRequestRef.current
}
const seq = requestSeq.current + 1
requestSeq.current = seq
if (mode === 'auto') lastAutoRefreshAtRef.current = Date.now()
setLoading(true)
setError(null)
try {
const inspection = await sessionsApi.getInspection(activeSessionId, {
includeContext: true,
contextOnly: true,
timeout: CONTEXT_REQUEST_TIMEOUT_MS,
const request = sessionsApi.getInspection(activeSessionId, {
includeContext: true,
contextOnly: true,
timeout: CONTEXT_REQUEST_TIMEOUT_MS,
})
.then((inspection) => {
if (seq !== requestSeq.current || activeContextIdentity !== contextIdentityRef.current) return
const nextContext = inspection.context ?? inspection.contextEstimate ?? null
const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null
const usageModel = inspection.usage?.models.find((model) => firstNonEmpty(model.displayName, model.model)) ?? null
setContext(nextContext)
setContextSource(nextSource)
setInspectionModel(firstNonEmpty(
inspection.context?.model,
inspection.contextEstimate?.model,
inspection.status?.model,
usageModel?.displayName,
usageModel?.model,
) ?? null)
setError(nextContext ? null : inspection.errors?.context ?? null)
setUpdatedAt(Date.now())
})
if (seq !== requestSeq.current) return
const nextContext = inspection.context ?? inspection.contextEstimate ?? null
const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null
const usageModel = inspection.usage?.models.find((model) => firstNonEmpty(model.displayName, model.model)) ?? null
setContext(nextContext)
setContextSource(nextSource)
setInspectionModel(firstNonEmpty(
inspection.context?.model,
inspection.contextEstimate?.model,
inspection.status?.model,
usageModel?.displayName,
usageModel?.model,
) ?? null)
setError(nextContext ? null : inspection.errors?.context ?? null)
setUpdatedAt(Date.now())
} catch (err) {
if (seq !== requestSeq.current) return
setError(err instanceof Error ? err.message : String(err))
} finally {
if (seq === requestSeq.current) setLoading(false)
}
}, [draft, sessionId])
.catch((err) => {
if (seq !== requestSeq.current || activeContextIdentity !== contextIdentityRef.current) return
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (inFlightRequestRef.current === request) {
inFlightRequestRef.current = null
inFlightIdentityRef.current = null
}
if (seq === requestSeq.current) setLoading(false)
})
inFlightRequestRef.current = request
inFlightIdentityRef.current = activeContextIdentity
return request
}, [draft, runtimeSelectionKey, sessionId])
useEffect(() => {
const contextIdentity = `${sessionId}:${runtimeSelectionKey}`
const identityChanged = contextIdentityRef.current !== contextIdentity
contextIdentityRef.current = contextIdentity
if (identityChanged) {
requestSeq.current += 1
lastAutoRefreshAtRef.current = 0
setContext(null)
setContextSource(null)
setError(null)
setUpdatedAt(null)
setInspectionModel(null)
}
void refresh()
void refresh('auto')
}, [messageCount, refresh, runtimeSelectionKey, sessionId])
useEffect(() => {
if (typeof document === 'undefined') return
const refreshIfVisible = () => {
if (!isDocumentVisible()) return
void refresh('auto')
}
document.addEventListener('visibilitychange', refreshIfVisible)
return () => document.removeEventListener('visibilitychange', refreshIfVisible)
}, [refresh])
useEffect(() => {
if (chatState === 'idle') return
const timer = setInterval(() => {
void refresh()
void refresh('auto')
}, ACTIVE_REFRESH_MS)
return () => clearInterval(timer)
}, [chatState, messageCount, refresh])
@ -178,7 +223,7 @@ export function ContextUsageIndicator({
if (compact) {
setMobileDetailsOpen(true)
}
void refresh()
void refresh('manual')
}}
title={t('contextIndicator.title')}
data-testid="context-usage-indicator"

View File

@ -119,6 +119,108 @@ describe('MessageList nested tool calls', () => {
})
})
it('window-renders long transcripts while keeping the latest messages visible', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: Array.from({ length: 220 }, (_, index) => ({
id: `assistant-${index}`,
type: 'assistant_text',
content: `assistant transcript line ${index}`,
timestamp: index,
})),
}),
},
})
const { container } = render(<MessageList />)
expect(screen.queryByText('assistant transcript line 0')).toBeNull()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(140)
})
it('restores the full transcript when scrolling away from latest', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: Array.from({ length: 220 }, (_, index) => ({
id: `assistant-${index}`,
type: 'assistant_text',
content: `assistant transcript line ${index}`,
timestamp: index,
})),
}),
},
})
const { container } = render(<MessageList />)
const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 220 * 112 })
await waitForProgrammaticScrollReset()
scrollArea.scrollTop = 0
await act(async () => {
fireEvent.scroll(scrollArea)
})
expect(screen.getByText('assistant transcript line 0')).toBeTruthy()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
})
it('window-renders long histories that include tool-call groups', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-read',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-1',
input: { file_path: '/tmp/example.ts' },
timestamp: 0,
},
{
id: 'tool-read-result',
type: 'tool_result',
toolUseId: 'read-1',
content: 'read result content',
isError: false,
timestamp: 1,
},
...Array.from({ length: 220 }, (_, index) => ({
id: `assistant-${index}`,
type: 'assistant_text' as const,
content: `assistant transcript line ${index}`,
timestamp: index + 2,
})),
],
}),
},
})
const { container } = render(<MessageList />)
const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 })
await waitForProgrammaticScrollReset()
expect(screen.queryByText('Read')).toBeNull()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
scrollArea.scrollTop = 0
await act(async () => {
fireEvent.scroll(scrollArea)
})
expect(screen.getByText('Read')).toBeTruthy()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
expect(container.querySelectorAll('[data-virtual-message-item]').length).toBe(221)
})
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
useChatStore.setState({
sessions: {

View File

@ -465,6 +465,8 @@ type MessageListProps = {
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const MAX_SCROLL_SNAPSHOTS = 100
const VIRTUALIZE_MIN_ITEMS = 160
const VIRTUAL_INITIAL_ITEMS = 96
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
'[scrollbar-width:auto]',
@ -512,6 +514,10 @@ function clampScrollTop(element: HTMLElement, scrollTop: number) {
return Math.max(0, Math.min(scrollTop, element.scrollHeight - element.clientHeight))
}
function getRenderItemKey(item: RenderItem) {
return item.kind === 'tool_group' ? item.id : item.message.id
}
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
@ -535,6 +541,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(null)
const t = useTranslation()
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
@ -575,6 +582,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
}
updateVirtualWindowRef.current?.(container)
}, [resolvedSessionId])
useLayoutEffect(() => {
@ -610,6 +618,58 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages),
[messages],
)
const shouldVirtualize = renderItems.length > VIRTUALIZE_MIN_ITEMS
const [virtualRange, setVirtualRange] = useState(() => ({ start: 0, end: 0 }))
const updateVirtualWindow = useCallback((container: HTMLElement | null) => {
if (!shouldVirtualize) {
setVirtualRange((current) =>
current.start === 0 && current.end === renderItems.length
? current
: { start: 0, end: renderItems.length },
)
return
}
if (!container || shouldAutoScrollRef.current) {
const start = Math.max(0, renderItems.length - VIRTUAL_INITIAL_ITEMS)
setVirtualRange((current) =>
current.start === start && current.end === renderItems.length
? current
: { start, end: renderItems.length },
)
return
}
setVirtualRange((current) =>
current.start === 0 && current.end === renderItems.length
? current
: { start: 0, end: renderItems.length },
)
}, [renderItems.length, shouldVirtualize])
useEffect(() => {
updateVirtualWindowRef.current = updateVirtualWindow
return () => {
if (updateVirtualWindowRef.current === updateVirtualWindow) {
updateVirtualWindowRef.current = null
}
}
}, [updateVirtualWindow])
useLayoutEffect(() => {
updateVirtualWindow(scrollContainerRef.current)
}, [renderItems.length, resolvedSessionId, updateVirtualWindow])
const visibleRenderItems = shouldVirtualize
? renderItems.slice(virtualRange.start, virtualRange.end)
: renderItems
const topVirtualSpacerHeight = shouldVirtualize
? Math.max(0, virtualRange.start * 96)
: 0
const bottomVirtualSpacerHeight = shouldVirtualize
? Math.max(0, renderItems.length - virtualRange.end) * 96
: 0
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
const latestCompletedTurnId =
completedTurnTargets.length > 0
@ -757,12 +817,27 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
onScroll={updateAutoScrollState}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
{renderItems.map((item, index) => {
<div
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
role={shouldVirtualize ? 'list' : undefined}
>
{topVirtualSpacerHeight > 0 && (
<div aria-hidden="true" style={{ height: topVirtualSpacerHeight }} />
)}
{visibleRenderItems.map((item, visibleIndex) => {
const index = shouldVirtualize ? virtualRange.start + visibleIndex : visibleIndex
const itemKey = getRenderItemKey(item)
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
return (
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
<div
key={itemKey}
data-virtual-message-item={shouldVirtualize ? itemKey : undefined}
role={shouldVirtualize ? 'listitem' : undefined}
aria-posinset={shouldVirtualize ? index + 1 : undefined}
aria-setsize={shouldVirtualize ? renderItems.length : undefined}
>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
@ -810,6 +885,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)
})}
{bottomVirtualSpacerHeight > 0 && (
<div aria-hidden="true" style={{ height: bottomVirtualSpacerHeight }} />
)}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}

View File

@ -980,6 +980,90 @@ describe('WebSocket Chat Integration', () => {
}
})
it('should let the global Thinking setting control DeepSeek desktop sessions', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'deepseek',
name: 'DeepSeek Thinking Toggle',
apiKey: 'key-deepseek-toggle',
baseUrl: 'https://api.deepseek.com/anthropic',
apiFormat: 'anthropic',
models: {
main: 'deepseek-v4-pro',
haiku: 'deepseek-v4-flash',
sonnet: 'deepseek-v4-pro',
opus: 'deepseek-v4-pro',
},
})
await providerService.activateProvider(provider.id)
const originalStartSession = conversationService.startSession.bind(conversationService)
const startOptions: Array<{
sessionId: string
thinking?: string
providerId?: string | null
}> = []
const sessionIds: 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 (sessionIds.includes(sid)) {
startOptions.push({
sessionId: sid,
thinking: options?.thinking,
providerId: options?.providerId,
})
}
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
try {
const disabledSessionId = `ds-think-off-${crypto.randomUUID()}`
sessionIds.push(disabledSessionId)
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ alwaysThinkingEnabled: false }, null, 2),
'utf-8',
)
const disabledMessages = await runTurn(disabledSessionId, 'DeepSeek with global thinking off')
expect(disabledMessages.some((m) => m.type === 'message_complete')).toBe(true)
const enabledSessionId = `ds-think-on-${crypto.randomUUID()}`
sessionIds.push(enabledSessionId)
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ alwaysThinkingEnabled: true }, null, 2),
'utf-8',
)
const enabledMessages = await runTurn(enabledSessionId, 'DeepSeek with global thinking on')
expect(enabledMessages.some((m) => m.type === 'message_complete')).toBe(true)
expect(startOptions).toEqual([
{
sessionId: disabledSessionId,
thinking: 'disabled',
providerId: provider.id,
},
{
sessionId: enabledSessionId,
thinking: undefined,
providerId: provider.id,
},
])
} finally {
conversationService.startSession = originalStartSession as typeof conversationService.startSession
for (const sessionId of sessionIds) {
conversationService.stopSession(sessionId)
}
await providerService.activateOfficial()
await fs.writeFile(path.join(tmpDir, 'settings.json'), '{}\n', 'utf-8')
}
}, 20_000)
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'),

View File

@ -119,4 +119,48 @@ describe('persistent storage upgrade migrations', () => {
const quarantined = (await listFiles(ccHahaDir)).filter((file) => file.startsWith('settings.json.invalid-'))
expect(quarantined.length).toBe(1)
})
test('upgrades existing DeepSeek managed env to follow global thinking settings', async () => {
const ccHahaDir = path.join(tempDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: 'deepseek-v4-pro',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'deepseek-v4-flash',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v4-pro',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'deepseek-v4-pro',
CC_HAHA_SEND_DISABLED_THINKING: '1',
USER_CUSTOM_ENV: 'keep-me',
},
}, null, 2),
'utf-8',
)
const report = await ensurePersistentStorageUpgraded()
expect(report.failures).toEqual([])
expect(report.migratedEntries).toContain('cc-haha/settings.json')
const migrated = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'settings.json'), 'utf-8')) as {
env?: Record<string, string>
}
expect(migrated.env?.CC_HAHA_SEND_DISABLED_THINKING).toBeUndefined()
expect(migrated.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(migrated.env?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(migrated.env?.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(migrated.env?.USER_CUSTOM_ENV).toBe('keep-me')
const backups = (await listFiles(ccHahaDir)).filter((file) => file.startsWith('settings.json.bak-before-migration-'))
expect(backups.length).toBe(1)
})
})

View File

@ -83,7 +83,10 @@ 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(deepseek?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBeUndefined()
expect(deepseek?.defaultEnv?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(zhipu?.authStrategy).toBe('auth_token')
expect(zhipu?.defaultModels.main).toBe('glm-5.1')
expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air')

View File

@ -196,6 +196,36 @@ describe('ProviderService', () => {
)
})
test('DeepSeek preset follows the global thinking toggle instead of forcing disabled thinking', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/anthropic',
models: {
main: 'deepseek-v4-pro',
haiku: 'deepseek-v4-flash',
sonnet: 'deepseek-v4-pro',
opus: 'deepseek-v4-pro',
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CC_HAHA_SEND_DISABLED_THINKING).toBeUndefined()
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
})
test('adding additional providers should keep activeId unchanged', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))

View File

@ -33,6 +33,56 @@ describe('titleService', () => {
},
})
try {
const providerId = 'zhipu-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: 'zhipuglm',
name: 'Zhipu GLM',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'glm-5.1',
haiku: 'glm-4.5-air',
sonnet: 'glm-5-turbo',
opus: 'glm-5.1',
},
},
],
}, null, 2),
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBody?.thinking).toEqual({ type: 'disabled' })
} finally {
server.stop(true)
}
})
test('does not force disabled thinking for DeepSeek title generation', async () => {
let requestBody: Record<string, unknown> | null = null
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
requestBody = await req.json() as Record<string, unknown>
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 })
@ -64,7 +114,7 @@ describe('titleService', () => {
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBody?.thinking).toEqual({ type: 'disabled' })
expect(requestBody?.thinking).toBeUndefined()
} finally {
server.stop(true)
}

View File

@ -29,7 +29,9 @@
"apiKeyUrl": "https://platform.deepseek.com/api_keys",
"authStrategy": "auth_token",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
"ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": "thinking,effort,adaptive_thinking,max_effort",
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "thinking,effort,adaptive_thinking,max_effort",
"ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": "thinking,effort,adaptive_thinking,max_effort"
},
"modelContextWindows": {
"deepseek-v4-pro": 1000000,

View File

@ -2,6 +2,7 @@ import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { randomBytes } from 'node:crypto'
import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js'
export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1
@ -123,6 +124,10 @@ function migrateManagedSettings(value: unknown): JsonObject {
if (value.env !== undefined && !isRecord(value.env)) {
return { ...value, env: {} }
}
if (isRecord(value.env)) {
const { env, changed } = normalizeLegacyDeepSeekManagedEnv(value.env as Record<string, string>)
if (changed) return { ...value, env }
}
return value
}

View File

@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import { normalizeLegacyDeepSeekManagedEnv } from '../providerManagedEnvCompat.js'
describe('provider managed env compatibility', () => {
test('normalizes legacy DeepSeek disabled-thinking env without dropping custom env vars', () => {
const { env, changed } = normalizeLegacyDeepSeekManagedEnv({
ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic',
ANTHROPIC_MODEL: 'deepseek-v4-pro',
CC_HAHA_SEND_DISABLED_THINKING: '1',
USER_CUSTOM_ENV: 'keep-me',
})
expect(changed).toBe(true)
expect(env.CC_HAHA_SEND_DISABLED_THINKING).toBeUndefined()
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,max_effort',
)
expect(env.USER_CUSTOM_ENV).toBe('keep-me')
})
test('does not change non-DeepSeek providers that still opt into disabled thinking', () => {
const input = {
ANTHROPIC_BASE_URL: 'https://open.bigmodel.cn/api/anthropic',
ANTHROPIC_MODEL: 'glm-5.1',
CC_HAHA_SEND_DISABLED_THINKING: '1',
}
const { env, changed } = normalizeLegacyDeepSeekManagedEnv(input)
expect(changed).toBe(false)
expect(env).toBe(input)
})
})

View File

@ -77,6 +77,19 @@ describe('provider-aware thinking support', () => {
expect(shouldSendExplicitDisabledThinking()).toBe(true)
})
test('DeepSeek preset can follow the global thinking setting through capability overrides', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES =
'thinking,effort,adaptive_thinking,max_effort'
delete process.env.CC_HAHA_SEND_DISABLED_THINKING
clearCapabilityCache()
expect(modelSupportsThinking('deepseek-v4-pro')).toBe(true)
expect(modelSupportsAdaptiveThinking('deepseek-v4-pro')).toBe(true)
expect(shouldSendExplicitDisabledThinking()).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

@ -8,6 +8,7 @@ import {
isProviderManagedEnvVar,
SAFE_ENV_VARS,
} from './managedEnvConstants.js'
import { normalizeLegacyDeepSeekManagedEnv } from './providerManagedEnvCompat.js'
import { clearMTLSCache } from './mtls.js'
import { clearProxyCache, configureGlobalAgents } from './proxy.js'
import { isSettingSourceEnabled } from './settings/constants.js'
@ -103,7 +104,7 @@ function getCcHahaSettingsEnv(): Record<string, string> {
const ccHahaSettings = join(getClaudeConfigHomeDir(), 'cc-haha', 'settings.json')
const raw = readFileSync(ccHahaSettings, 'utf-8')
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
return parsed.env ?? {}
return normalizeLegacyDeepSeekManagedEnv(parsed.env ?? {}).env
} catch {
return {}
}

View File

@ -0,0 +1,39 @@
const DEEPSEEK_THINKING_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort'
const DEEPSEEK_CAPABILITY_ENV_KEYS = [
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
] as const
function looksLikeDeepSeekManagedEnv(env: Record<string, string>): boolean {
const baseUrl = env.ANTHROPIC_BASE_URL ?? ''
const modelIds = [
env.ANTHROPIC_MODEL,
env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
env.ANTHROPIC_DEFAULT_SONNET_MODEL,
env.ANTHROPIC_DEFAULT_OPUS_MODEL,
].filter(Boolean)
return (
baseUrl.includes('api.deepseek.com') ||
modelIds.some((model) => /^deepseek[-_]/i.test(model ?? ''))
)
}
export function normalizeLegacyDeepSeekManagedEnv(
env: Record<string, string>,
): { env: Record<string, string>; changed: boolean } {
if (!env.CC_HAHA_SEND_DISABLED_THINKING || !looksLikeDeepSeekManagedEnv(env)) {
return { env, changed: false }
}
const next = { ...env }
delete next.CC_HAHA_SEND_DISABLED_THINKING
for (const key of DEEPSEEK_CAPABILITY_ENV_KEYS) {
next[key] = DEEPSEEK_THINKING_CAPABILITIES
}
return { env: next, changed: true }
}