mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Bring responsive desktop sessions and thinking controls onto main
This merge carries the worktree fix into the local main branch so long multi-session desktop usage keeps rendering cost bounded while DeepSeek follows the shared General Settings Thinking toggle. It preserves running-session behavior and keeps legacy DeepSeek managed env from pinning thinking off after upgrade. Constraint: User requested the worktree result merged into local main without pushing. Confidence: high Scope-risk: moderate Directive: Keep DeepSeek controlled by the global Thinking setting; do not restore provider-specific disabled-thinking defaults without both toggle-state tests. 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:
commit
45ec701124
268
desktop/src/components/chat/ContextUsageIndicator.test.tsx
Normal file
268
desktop/src/components/chat/ContextUsageIndicator.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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'} />
|
||||
)}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -233,4 +233,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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -152,6 +153,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
|
||||
}
|
||||
|
||||
|
||||
39
src/utils/__tests__/providerManagedEnvCompat.test.ts
Normal file
39
src/utils/__tests__/providerManagedEnvCompat.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
39
src/utils/providerManagedEnvCompat.ts
Normal file
39
src/utils/providerManagedEnvCompat.ts
Normal 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 }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user