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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-21 00:41:02 +08:00
parent e78b973e8c
commit 7651b24a50
5 changed files with 66 additions and 21 deletions

View File

@ -39,7 +39,7 @@ type SettingsStore = {
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
effortLevel: 'high',
effortLevel: 'medium',
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),

View File

@ -2723,16 +2723,30 @@ async function run(): Promise<CommanderCommand> {
}));
}, 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.

View File

@ -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')
})
})

View File

@ -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()

View File

@ -64,6 +64,33 @@ export class ConversationStartupError extends Error {
export class ConversationService {
private sessions = new Map<string, SessionProcess>()
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)`,