fix: Preserve ChatGPT Official after desktop restart

The WebSocket runtime treated every active provider id as stale unless it existed in the saved custom-provider list. ChatGPT Official is an in-memory built-in provider, so restarting the desktop could activate Claude Official and rewrite the provider index back to null before a new session launched. Keep built-in OpenAI provider ids valid in runtime validation, while preserving the stale custom-provider cleanup path.

This also keeps the General network timeout UI aligned with the authoritative timeout behavior by allowing precise typed values and updating the user-facing hint.

Constraint: ChatGPT Official is a built-in provider id and is not stored in the custom providers array.
Rejected: Persist ChatGPT Official as a synthetic saved provider | it would mix token-backed built-ins with user-managed providers and complicate secret persistence.
Confidence: high
Scope-risk: moderate
Directive: Runtime provider validation must include built-in provider ids as well as saved custom provider ids.
Tested: bun test src/server/__tests__/conversations.test.ts -t "preserve ChatGPT Official"
Tested: bun test src/server/__tests__/conversations.test.ts -t "stale persisted|preserve ChatGPT Official"
Tested: bun run check:server
Tested: bun run check:desktop
Not-tested: Full bun run verify after the final interruption; user explicitly asked to stop verification and commit directly.
Not-tested: Manual desktop restart with a real ChatGPT OAuth account in the packaged app.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-20 18:00:22 +08:00
parent 4343b3f039
commit 02bf65ce07
6 changed files with 214 additions and 28 deletions

View File

@ -260,7 +260,7 @@ describe('Settings > General tab', () => {
updateH5AccessSettings: vi.fn(),
})
useUIStore.setState({ pendingSettingsTab: null })
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
useUpdateStore.setState({
status: 'idle',
availableVersion: null,
@ -345,7 +345,11 @@ describe('Settings > General tab', () => {
expect(saveButton).toBeDisabled()
fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } })
fireEvent.change(screen.getByLabelText('AI request timeout'), { target: { value: '180' } })
const timeoutInput = screen.getByLabelText('AI request timeout')
expect(timeoutInput).toHaveAttribute('type', 'number')
expect(screen.queryByRole('slider', { name: 'AI request timeout' })).not.toBeInTheDocument()
fireEvent.change(timeoutInput, { target: { value: '180' } })
await act(async () => {
fireEvent.click(saveButton)
@ -358,6 +362,31 @@ describe('Settings > General tab', () => {
url: 'http://127.0.0.1:7890',
},
})
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
type: 'success',
message: 'Network settings saved.',
})
})
it('validates typed provider network timeout and supports precise step controls', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const timeoutInput = screen.getByLabelText('AI request timeout')
const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]!
fireEvent.change(timeoutInput, { target: { value: '700' } })
expect(screen.getByText('Enter a whole number from 5 to 600 seconds.')).toBeInTheDocument()
expect(saveButton).toBeDisabled()
fireEvent.change(timeoutInput, { target: { value: '90' } })
fireEvent.click(screen.getByRole('button', { name: 'Increase by 30 seconds' }))
expect(timeoutInput).toHaveValue(120)
fireEvent.click(screen.getByRole('button', { name: 'Decrease by 30 seconds' }))
fireEvent.click(screen.getByRole('button', { name: 'Decrease by 30 seconds' }))
expect(timeoutInput).toHaveValue(60)
expect(saveButton).not.toBeDisabled()
})
it('keeps data storage at the bottom of General settings', () => {

View File

@ -909,7 +909,13 @@ export const en = {
'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.',
'settings.general.networkTimeout': 'AI request timeout',
'settings.general.networkTimeoutValue': '{seconds}s',
'settings.general.networkTimeoutHint': 'Applies to the first response from streaming provider requests and provider connection tests.',
'settings.general.networkTimeoutHint': 'Applies to provider requests, streaming first responses, and provider connection tests. Supports 5-600 seconds.',
'settings.general.networkTimeoutUnit': 'sec',
'settings.general.networkTimeoutDecrease': 'Decrease by 30 seconds',
'settings.general.networkTimeoutIncrease': 'Increase by 30 seconds',
'settings.general.networkTimeoutRequired': 'Enter a timeout.',
'settings.general.networkTimeoutRange': 'Enter a whole number from {min} to {max} seconds.',
'settings.general.networkSaved': 'Network settings saved.',
'settings.general.networkScopeHint': 'Does not change the separate app update proxy.',
'settings.general.networkSave': 'Save',
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',

View File

@ -911,7 +911,13 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.networkProxyUrlRequired': '请输入代理地址。',
'settings.general.networkTimeout': 'AI 请求超时',
'settings.general.networkTimeoutValue': '{seconds} 秒',
'settings.general.networkTimeoutHint': '用于流式服务商请求的首个响应,以及服务商连接测试。',
'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 5-600 秒。',
'settings.general.networkTimeoutUnit': '秒',
'settings.general.networkTimeoutDecrease': '减少 30 秒',
'settings.general.networkTimeoutIncrease': '增加 30 秒',
'settings.general.networkTimeoutRequired': '请输入超时时间。',
'settings.general.networkTimeoutRange': '请输入 {min}-{max} 秒之间的整数。',
'settings.general.networkSaved': '网络设置已保存。',
'settings.general.networkScopeHint': '不会影响单独的应用更新代理。',
'settings.general.networkSave': '保存',
'settings.general.webFetchPreflightTitle': 'WebFetch 预检',

View File

@ -52,6 +52,10 @@ import {
} from '../lib/providerSettingsJson'
import { copyTextToClipboard } from '../components/chat/clipboard'
const NETWORK_TIMEOUT_MIN_SECONDS = 5
const NETWORK_TIMEOUT_MAX_SECONDS = 600
const NETWORK_TIMEOUT_STEP_SECONDS = 30
function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null {
if (!baseUrl) return null
@ -1444,6 +1448,7 @@ function GeneralSettings() {
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const [networkDraft, setNetworkDraft] = useState(network)
const [networkTimeoutInput, setNetworkTimeoutInput] = useState(String(Math.round(network.aiRequestTimeoutMs / 1000)))
const [networkSaveError, setNetworkSaveError] = useState<string | null>(null)
const [isSavingNetwork, setIsSavingNetwork] = useState(false)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
@ -1457,6 +1462,7 @@ function GeneralSettings() {
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
const [isUiZoomDragging, setIsUiZoomDragging] = useState(false)
const isUiZoomDraggingRef = useRef(false)
const addToast = useUIStore((s) => s.addToast)
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
const uiZoomPercent = Math.round(uiZoomDraft * 100)
const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%`
@ -1470,6 +1476,7 @@ function GeneralSettings() {
useEffect(() => {
setNetworkDraft(network)
setNetworkTimeoutInput(String(Math.round(network.aiRequestTimeoutMs / 1000)))
setNetworkSaveError(null)
}, [network])
@ -1624,27 +1631,64 @@ function GeneralSettings() {
? t('settings.general.networkProxyUrlInvalid')
: null
const timeoutSeconds = Math.round(networkDraft.aiRequestTimeoutMs / 1000)
const parsedNetworkTimeoutSeconds = (() => {
const trimmed = networkTimeoutInput.trim()
if (!/^\d+$/.test(trimmed)) return null
const seconds = Number(trimmed)
if (!Number.isFinite(seconds) || seconds < NETWORK_TIMEOUT_MIN_SECONDS || seconds > NETWORK_TIMEOUT_MAX_SECONDS) return null
return seconds
})()
const networkTimeoutError =
networkTimeoutInput.trim().length === 0
? t('settings.general.networkTimeoutRequired')
: parsedNetworkTimeoutSeconds === null
? t('settings.general.networkTimeoutRange', {
min: String(NETWORK_TIMEOUT_MIN_SECONDS),
max: String(NETWORK_TIMEOUT_MAX_SECONDS),
})
: null
const networkDirty =
networkDraft.aiRequestTimeoutMs !== network.aiRequestTimeoutMs ||
networkDraft.proxy.mode !== network.proxy.mode ||
networkDraft.proxy.url.trim() !== network.proxy.url.trim()
const setNetworkTimeoutSeconds = (seconds: number) => {
const nextSeconds = Math.min(Math.max(Math.round(seconds), NETWORK_TIMEOUT_MIN_SECONDS), NETWORK_TIMEOUT_MAX_SECONDS)
setNetworkTimeoutInput(String(nextSeconds))
setNetworkDraft((current) => ({
...current,
aiRequestTimeoutMs: nextSeconds * 1000,
}))
setNetworkSaveError(null)
}
const saveNetworkSettings = async () => {
if (networkProxyError) {
setNetworkSaveError(networkProxyError)
return
}
if (networkTimeoutError || parsedNetworkTimeoutSeconds === null) {
setNetworkSaveError(networkTimeoutError ?? t('settings.general.networkTimeoutRange', {
min: String(NETWORK_TIMEOUT_MIN_SECONDS),
max: String(NETWORK_TIMEOUT_MAX_SECONDS),
}))
return
}
setIsSavingNetwork(true)
setNetworkSaveError(null)
try {
await setNetwork({
aiRequestTimeoutMs: networkDraft.aiRequestTimeoutMs,
aiRequestTimeoutMs: parsedNetworkTimeoutSeconds * 1000,
proxy: {
mode: networkDraft.proxy.mode,
url: networkProxyUrl,
},
})
addToast({
type: 'success',
message: t('settings.general.networkSaved'),
})
} catch (error) {
setNetworkSaveError(error instanceof Error ? error.message : String(error))
} finally {
@ -2047,26 +2091,67 @@ function GeneralSettings() {
{t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })}
</span>
</div>
<input
id="network-timeout-seconds"
type="range"
min={5}
max={600}
step={5}
value={timeoutSeconds}
aria-label={t('settings.general.networkTimeout')}
onChange={(event) => {
const seconds = Number(event.currentTarget.value)
setNetworkDraft((current) => ({
...current,
aiRequestTimeoutMs: seconds * 1000,
}))
setNetworkSaveError(null)
}}
className="settings-zoom-range w-full"
/>
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.general.networkTimeoutHint')}
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="secondary"
className="h-10 w-10 px-0"
aria-label={t('settings.general.networkTimeoutDecrease')}
onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) - NETWORK_TIMEOUT_STEP_SECONDS)}
>
-30
</Button>
<div className="relative min-w-0 flex-1">
<input
id="network-timeout-seconds"
type="number"
min={NETWORK_TIMEOUT_MIN_SECONDS}
max={NETWORK_TIMEOUT_MAX_SECONDS}
step={1}
inputMode="numeric"
value={networkTimeoutInput}
aria-invalid={networkTimeoutError ? true : undefined}
aria-describedby="network-timeout-help"
onChange={(event) => {
const nextValue = event.currentTarget.value
if (!/^\d*$/.test(nextValue)) return
setNetworkTimeoutInput(nextValue)
const seconds = Number(nextValue)
if (nextValue.length > 0 && seconds >= NETWORK_TIMEOUT_MIN_SECONDS && seconds <= NETWORK_TIMEOUT_MAX_SECONDS) {
setNetworkDraft((current) => ({
...current,
aiRequestTimeoutMs: seconds * 1000,
}))
}
setNetworkSaveError(null)
}}
className={`h-10 w-full rounded-[var(--radius-md)] border bg-[var(--color-surface)] px-3 pr-12 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] ${
networkTimeoutError
? 'border-[var(--color-error)] focus:shadow-[var(--shadow-error-ring)]'
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]'
}`}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.networkTimeoutUnit')}
</span>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className="h-10 w-10 px-0"
aria-label={t('settings.general.networkTimeoutIncrease')}
onClick={() => setNetworkTimeoutSeconds((parsedNetworkTimeoutSeconds ?? timeoutSeconds) + NETWORK_TIMEOUT_STEP_SECONDS)}
>
+30
</Button>
</div>
<p
id="network-timeout-help"
className={`mt-2 text-xs leading-5 ${networkTimeoutError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}
>
{networkTimeoutError ?? t('settings.general.networkTimeoutHint')}
</p>
</div>
@ -2078,7 +2163,7 @@ function GeneralSettings() {
size="sm"
variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!networkDirty || !!networkProxyError || isSavingNetwork}
disabled={!networkDirty || !!networkProxyError || !!networkTimeoutError || isSavingNetwork}
loading={isSavingNetwork}
onClick={() => void saveNetworkSettings()}
>

View File

@ -2411,6 +2411,55 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
it('should preserve ChatGPT Official as the active default runtime after restart', async () => {
const providerService = new ProviderService()
await providerService.activateProvider('openai-official')
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd() }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
const startCalls: Array<{
sessionId: string
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
}> = []
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 },
) {
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
try {
const messages = await runTurn(sessionId, 'default ChatGPT Official runtime')
expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
providerId: 'openai-official',
},
})
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
await expect(providerService.listProviders()).resolves.toMatchObject({
activeId: 'openai-official',
})
} finally {
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
await providerService.activateOfficial()
}
}, 20_000)
it('should resume streaming to a reconnected client during an active turn', async () => {
await withMockStreamDelay(150, async () => {
const sessionId = `chat-reconnect-${crypto.randomUUID()}`

View File

@ -17,6 +17,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi
import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
@ -1638,12 +1639,22 @@ type RuntimeSettings = {
providerId?: string | null
}
function isKnownRuntimeProviderId(
providerId: string,
providers: Array<{ id: string }>,
): boolean {
return (
isOpenAIOfficialProviderId(providerId) ||
providers.some((provider) => provider.id === providerId)
)
}
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
if (runtimeOverride) {
if (typeof runtimeOverride.providerId === 'string') {
const { providers } = await providerService.listProviders()
const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId)
const providerExists = isKnownRuntimeProviderId(runtimeOverride.providerId, providers)
if (!providerExists) {
console.warn(
`[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
@ -1676,7 +1687,7 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
// Check if a custom provider is active
const { providers, activeId } = await providerService.listProviders()
let resolvedActiveId = activeId
if (activeId && !providers.some((provider) => provider.id === activeId)) {
if (activeId && !isKnownRuntimeProviderId(activeId, providers)) {
console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`)
resolvedActiveId = null
await providerService.activateOfficial()