From 7651b24a50297ba5b647c1bc46bd1c74924e7363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 21 Apr 2026 00:41:02 +0800 Subject: [PATCH] Reduce desktop first-token latency in sdk-url sessions Desktop-managed sessions were blocking the first visible token on slow or failing regular MCP connections, which made the UI feel much slower than direct bin runs. This keeps partial assistant streaming enabled for desktop sessions and moves regular MCP connection work off the critical path only for sdk-url startup. Constraint: Desktop sdk-url sessions must preserve chat responsiveness even when local MCP endpoints fail or hang Rejected: Leave MCP startup fully blocking in sdk-url mode | first visible token stayed several seconds behind direct CLI runs Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep blocking MCP startup for plain -p sessions unless you re-measure first-turn latency and tool availability tradeoffs Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/conversations.test.ts; same-prompt timing comparison for direct bin vs sdk-url session path Not-tested: Immediate first-turn availability of slow regular MCP tools in desktop sdk-url sessions --- desktop/src/stores/settingsStore.ts | 2 +- src/main.tsx | 22 +++++++-- .../__tests__/conversation-service.test.ts | 14 ++++++ src/server/api/models.ts | 2 +- src/server/services/conversationService.ts | 47 +++++++++++++------ 5 files changed, 66 insertions(+), 21 deletions(-) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index a08d0fdb..2fa8f56e 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -39,7 +39,7 @@ type SettingsStore = { export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, - effortLevel: 'high', + effortLevel: 'medium', availableModels: [], activeProviderName: null, locale: getStoredLocale(), diff --git a/src/main.tsx b/src/main.tsx index 0b0ef6f7..7fa988bf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2723,16 +2723,30 @@ async function run(): Promise { })); }, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`)); }; - // Await all MCP configs — print mode is often single-turn, so - // "late-connecting servers visible next turn" doesn't help. SDK init - // message and turn-1 tool list both need configured MCP tools present. + // Await all MCP configs for regular print-mode sessions — they're + // often single-turn, so "late-connecting servers visible next turn" + // doesn't help. SDK init message and turn-1 tool list should include + // configured MCP tools when running plain `claude -p`. + // + // Desktop/bridge sessions (`--sdk-url`) are different: the user is + // waiting on the first visible assistant token, not a fully populated + // MCP inventory. Blocking startup on slow or failing MCP servers (for + // example a local `chatlog` HTTP endpoint timing out) makes the whole + // chat feel frozen before the first word appears. In that mode we still + // kick off the connections immediately, but let them settle in the + // background so headlessStore picks them up after the session starts. + // // Zero-server case is free via the early return in connectMcpBatch. // Connectors parallelize inside getMcpToolsCommandsAndResources // (processBatched with Promise.all). claude.ai is awaited too — its // fetch was kicked off early (line ~2558) so only residual time blocks // here. --bare skips claude.ai entirely for perf-sensitive scripts. profileCheckpoint('before_connectMcp'); - await connectMcpBatch(regularMcpConfigs, 'regular'); + if (sdkUrl) { + void connectMcpBatch(regularMcpConfigs, 'regular'); + } else { + await connectMcpBatch(regularMcpConfigs, 'regular'); + } profileCheckpoint('after_connectMcp'); // Dedup: suppress plugin MCP servers that duplicate a claude.ai // connector (connector wins), then connect claude.ai servers. diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 249a3439..7e049c06 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -170,4 +170,18 @@ describe('ConversationService', () => { expect(args[0]).toContain(path.join('bin', 'claude-haha')) } }) + + test('buildSessionCliArgs enables partial assistant messages for desktop streaming', () => { + const service = new ConversationService() as any + const args = service.buildSessionCliArgs( + '123e4567-e89b-12d3-a456-426614174000', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + false, + { permissionMode: 'bypassPermissions' }, + ) as string[] + + expect(args).toContain('--include-partial-messages') + expect(args).toContain('--sdk-url') + expect(args).toContain('--replay-user-messages') + }) }) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index c10d4fe1..f90d530b 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -38,7 +38,7 @@ const DEFAULT_MODELS = [ const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] as const const DEFAULT_MODEL = 'claude-opus-4-7' -const DEFAULT_EFFORT = 'max' +const DEFAULT_EFFORT = 'medium' const settingsService = new SettingsService() const providerService = new ProviderService() diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 66542aea..c0706e13 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -64,6 +64,33 @@ export class ConversationStartupError extends Error { export class ConversationService { private sessions = new Map() + private buildSessionCliArgs( + sessionId: string, + sdkUrl: string, + shouldResume: boolean, + options?: SessionStartOptions, + ): string[] { + const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1' + return this.resolveCliArgs([ + '--print', + '--verbose', + '--sdk-url', + sdkUrl, + '--enable-auth-status', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + // Desktop chat depends on partial assistant deltas; without this the + // server only sees the completed assistant message at turn end. + '--include-partial-messages', + ...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]), + '--replay-user-messages', + ...this.getRuntimeArgs(options), + ...this.getPermissionArgs(options?.permissionMode, dangerousMode), + ]) + } + async startSession( sessionId: string, workDir: string, @@ -88,22 +115,12 @@ export class ConversationService { ) } - const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1' - const args = this.resolveCliArgs([ - '--print', - '--verbose', - '--sdk-url', + const args = this.buildSessionCliArgs( + sessionId, sdkUrl, - '--enable-auth-status', - '--input-format', - 'stream-json', - '--output-format', - 'stream-json', - ...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]), - '--replay-user-messages', - ...this.getRuntimeArgs(options), - ...this.getPermissionArgs(options?.permissionMode, dangerousMode), - ]) + shouldResume, + options, + ) console.log( `[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`,