diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 9f1c0cd8..57e0b981 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -468,8 +468,8 @@ describe('Settings > General tab', () => { 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() + fireEvent.change(timeoutInput, { target: { value: '2000' } }) + expect(screen.getByText('Enter a whole number from 30 to 1800 seconds.')).toBeInTheDocument() expect(saveButton).toBeDisabled() fireEvent.change(timeoutInput, { target: { value: '90' } }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5ee633d2..528681f9 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1074,7 +1074,7 @@ 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 provider requests, streaming first responses, and provider connection tests. Supports 5-600 seconds.', + 'settings.general.networkTimeoutHint': 'Applies to provider requests, streaming first responses, and provider connection tests. Supports 30-1800 seconds; slow providers may need several minutes before the first streamed byte.', 'settings.general.networkTimeoutUnit': 'sec', 'settings.general.networkTimeoutDecrease': 'Decrease by 30 seconds', 'settings.general.networkTimeoutIncrease': 'Increase by 30 seconds', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index dcf51b5e..a4507e7f 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1076,7 +1076,7 @@ export const jp: Record = { 'settings.general.networkProxyUrlRequired': 'プロキシ URL を入力してください。', 'settings.general.networkTimeout': 'AI リクエストのタイムアウト', 'settings.general.networkTimeoutValue': '{seconds}秒', - 'settings.general.networkTimeoutHint': 'プロバイダーへのリクエスト、ストリーミングの最初の応答、プロバイダー接続テストに適用されます。5〜600 秒に対応します。', + 'settings.general.networkTimeoutHint': 'プロバイダーへのリクエスト、ストリーミングの最初の応答、プロバイダー接続テストに適用されます。30〜1800 秒に対応します。大きなコンテキストでは最初のストリーミングバイトまで数分かかる場合があります。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '30 秒減らす', 'settings.general.networkTimeoutIncrease': '30 秒増やす', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 9ace308b..4fd1db08 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1076,7 +1076,7 @@ export const kr: Record = { 'settings.general.networkProxyUrlRequired': '프록시 URL을 입력하세요.', 'settings.general.networkTimeout': 'AI 요청 시간 초과', 'settings.general.networkTimeoutValue': '{seconds}초', - 'settings.general.networkTimeoutHint': '공급자 요청, 스트리밍 첫 응답, 공급자 연결 테스트에 적용됩니다. 5~600초를 지원합니다.', + 'settings.general.networkTimeoutHint': '공급자 요청, 스트리밍 첫 응답, 공급자 연결 테스트에 적용됩니다. 30~1800초를 지원합니다. 큰 컨텍스트에서는 첫 스트리밍 바이트까지 몇 분이 걸릴 수 있습니다.', 'settings.general.networkTimeoutUnit': '초', 'settings.general.networkTimeoutDecrease': '30초 줄이기', 'settings.general.networkTimeoutIncrease': '30초 늘리기', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index c69a3249..3c39aa7c 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1076,7 +1076,7 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '請輸入代理地址。', 'settings.general.networkTimeout': 'AI 請求超時', 'settings.general.networkTimeoutValue': '{seconds} 秒', - 'settings.general.networkTimeoutHint': '用於服務商請求、流式首個響應,以及服務商連線測試。支援 5-600 秒。', + 'settings.general.networkTimeoutHint': '用於服務商請求、流式首個響應,以及服務商連線測試。支援 30-1800 秒;部分服務商在大上下文下首個流式位元組可能需要等待數分鐘。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '減少 30 秒', 'settings.general.networkTimeoutIncrease': '增加 30 秒', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7c714b98..396ba4d5 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1076,7 +1076,7 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '请输入代理地址。', 'settings.general.networkTimeout': 'AI 请求超时', 'settings.general.networkTimeoutValue': '{seconds} 秒', - 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 5-600 秒。', + 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 30-1800 秒;部分服务商在大上下文下首个流式字节可能需要等待数分钟。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '减少 30 秒', 'settings.general.networkTimeoutIncrease': '增加 30 秒', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2e44355d..6d806406 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -57,8 +57,8 @@ 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_MIN_SECONDS = 30 +const NETWORK_TIMEOUT_MAX_SECONDS = 1800 const NETWORK_TIMEOUT_STEP_SECONDS = 30 const SETTINGS_CHECKBOX_INPUT_CLASS = 'settings-checkbox-input peer' diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 670b7a15..2e156f23 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -174,7 +174,7 @@ describe('settingsStore network persistence', () => { window.localStorage.clear() }) - it('defaults old user settings to 120s system network settings', async () => { + it('defaults old user settings to 600s system network settings', async () => { vi.doMock('../api/settings', () => ({ settingsApi: { getUser: vi.fn().mockResolvedValue({}), @@ -215,7 +215,7 @@ describe('settingsStore network persistence', () => { await useSettingsStore.getState().fetchAll() expect(useSettingsStore.getState().network).toEqual({ - aiRequestTimeoutMs: 120_000, + aiRequestTimeoutMs: 600_000, proxy: { mode: 'system', url: '', @@ -256,7 +256,7 @@ describe('settingsStore network persistence', () => { const { useSettingsStore } = await import('./settingsStore') await useSettingsStore.getState().setNetwork({ - aiRequestTimeoutMs: 999_999, + aiRequestTimeoutMs: 9_999_999, proxy: { mode: 'manual', url: ' http://127.0.0.1:7890 ', @@ -264,7 +264,7 @@ describe('settingsStore network persistence', () => { }) expect(useSettingsStore.getState().network).toEqual({ - aiRequestTimeoutMs: 600_000, + aiRequestTimeoutMs: 1_800_000, proxy: { mode: 'manual', url: 'http://127.0.0.1:7890', @@ -272,7 +272,7 @@ describe('settingsStore network persistence', () => { }) expect(updateUser).toHaveBeenCalledWith({ network: { - aiRequestTimeoutMs: 600_000, + aiRequestTimeoutMs: 1_800_000, proxy: { mode: 'manual', url: 'http://127.0.0.1:7890', diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index cd33fdcf..46d4b4ba 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -149,7 +149,7 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = { } const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { - aiRequestTimeoutMs: 120_000, + aiRequestTimeoutMs: 600_000, proxy: { mode: 'system', url: '', @@ -655,7 +655,7 @@ function normalizeNetworkSettings( settings: NetworkSettingsInput | undefined, ): NetworkSettings { const timeout = typeof settings?.aiRequestTimeoutMs === 'number' && Number.isFinite(settings.aiRequestTimeoutMs) - ? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 5_000), 600_000) + ? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 30_000), 1_800_000) : DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs const proxyMode = settings?.proxy?.mode === 'manual' ? 'manual' : 'system' diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 67ce5f7d..6f23aa08 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -727,6 +727,52 @@ describe('ConversationService', () => { expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('1') }) + test('buildChildEnv widens the stream idle window and disables the non-streaming fallback (#766)', async () => { + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + )) as Record + + // 90s default kills healthy-but-silent third-party streams; 240s keeps the + // watchdog useful without aborting slow thinking/prefill phases. + expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('240000') + // Non-streaming fallback can never finish for slow providers (first byte + // only arrives after FULL generation), so retries must stay streaming. + expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('1') + }) + + test('buildChildEnv respects caller overrides for stream timeout tuning envs', async () => { + const service = new ConversationService() as any + const previous = { + watchdog: process.env.CLAUDE_ENABLE_STREAM_WATCHDOG, + idle: process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS, + fallback: process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK, + } + process.env.CLAUDE_ENABLE_STREAM_WATCHDOG = '0' + process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = '90000' + process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK = '0' + try { + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + )) as Record + + expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('0') + expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('90000') + expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('0') + } finally { + for (const [key, value] of [ + ['CLAUDE_ENABLE_STREAM_WATCHDOG', previous.watchdog], + ['CLAUDE_STREAM_IDLE_TIMEOUT_MS', previous.idle], + ['CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK', previous.fallback], + ] as const) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => { const service = new ConversationService() as any const args = service.buildSessionCliArgs( diff --git a/src/server/__tests__/network-settings.test.ts b/src/server/__tests__/network-settings.test.ts index 69dad9e0..e8618fab 100644 --- a/src/server/__tests__/network-settings.test.ts +++ b/src/server/__tests__/network-settings.test.ts @@ -37,7 +37,7 @@ describe('network settings', () => { beforeEach(setup) afterEach(teardown) - it('normalizes missing settings to the 120s system-proxy default', () => { + it('normalizes missing settings to the 600s system-proxy default', () => { expect(normalizeNetworkSettings({})).toEqual({ aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS, proxy: { @@ -50,7 +50,7 @@ describe('network settings', () => { it('clamps AI request timeouts and trims manual proxy URLs', () => { expect(normalizeNetworkSettings({ network: { - aiRequestTimeoutMs: 999_999, + aiRequestTimeoutMs: 9_999_999, proxy: { mode: 'manual', url: ' http://127.0.0.1:7890 ', diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 0945f2bf..de1a54fe 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -1109,6 +1109,18 @@ export class ConversationService { CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', // Desktop must fail stuck provider streams instead of leaving the UI running forever. CLAUDE_ENABLE_STREAM_WATCHDOG: cleanEnv.CLAUDE_ENABLE_STREAM_WATCHDOG || '1', + // Third-party providers can stay silent for minutes mid-stream (thinking + // phases emit no SSE bytes, and many gateways never send pings), so the + // CLI's 90s idle default kills healthy streams (#766). 240s still frees + // a truly dead connection without shooting slow ones. + CLAUDE_STREAM_IDLE_TIMEOUT_MS: cleanEnv.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '240000', + // When a stream does get aborted, retry as streaming instead of falling + // back to non-streaming: a non-streaming request must wait for the FULL + // generation before the first response byte, so slow providers can never + // finish inside API_TIMEOUT_MS — the fallback loops 5-minute aborts + // forever while the UI shows "running" (#766). It can also double-run + // tools (upstream inc-4258). + CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: cleanEnv.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK || '1', CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath, CLAUDE_COWORK_MEMORY_PATH_OVERRIDE: this.resolveDesktopAutoMemoryPath(workDir), CALLER_DIR: workDir, diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts index b97f70c2..33d653e4 100644 --- a/src/server/services/networkSettings.ts +++ b/src/server/services/networkSettings.ts @@ -10,9 +10,15 @@ export type NetworkSettings = { } } -export const DEFAULT_AI_REQUEST_TIMEOUT_MS = 120_000 -export const MIN_AI_REQUEST_TIMEOUT_MS = 5_000 -export const MAX_AI_REQUEST_TIMEOUT_MS = 600_000 +// API_TIMEOUT_MS is the CLI's *time-to-first-byte* budget for streaming +// requests: third-party Anthropic-compatible gateways (sensenova, bailian, +// zhipu, ...) often send no response bytes — not even headers or an SSE ping — +// until prefill finishes, which can take minutes at large contexts. A short +// default here aborts those healthy-but-slow requests dead at exactly this +// timeout (#766), so the default matches the SDK's own 600s. +export const DEFAULT_AI_REQUEST_TIMEOUT_MS = 600_000 +export const MIN_AI_REQUEST_TIMEOUT_MS = 30_000 +export const MAX_AI_REQUEST_TIMEOUT_MS = 1_800_000 const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS,