From e3a0de6284c9a00459eb9a0b2627d10278033685 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 8 Apr 2026 22:46:04 +0800 Subject: [PATCH 01/50] fix(session): honor cc presets in claude sdk sessions --- src/agent/providers/claude-code-sdk.ts | 7 +- src/agent/session-manager.ts | 35 +++-- src/agent/transport-provider.ts | 2 + src/agent/transport-session-runtime.ts | 3 + src/daemon/cc-presets.ts | 29 ++++ src/daemon/command-handler.ts | 34 +++++ test/agent/claude-code-sdk-provider.test.ts | 23 ++++ test/daemon/cc-presets.test.ts | 1 + test/e2e/sdk-transport-flow.test.ts | 139 ++++++++++++++++++++ 9 files changed, 262 insertions(+), 11 deletions(-) diff --git a/src/agent/providers/claude-code-sdk.ts b/src/agent/providers/claude-code-sdk.ts index 8fb1a2f7..c2e077e3 100644 --- a/src/agent/providers/claude-code-sdk.ts +++ b/src/agent/providers/claude-code-sdk.ts @@ -31,6 +31,7 @@ interface ClaudeSdkSessionState { env?: Record; model?: string; description?: string; + systemPrompt?: string; permissionMode: PermissionMode; effort?: TransportEffortLevel; started: boolean; @@ -123,6 +124,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { env: config.env ?? existing?.env, model: typeof config.agentId === 'string' ? config.agentId : existing?.model, description: config.description ?? existing?.description, + systemPrompt: config.systemPrompt ?? existing?.systemPrompt, permissionMode: this.resolvePermissionMode(), effort: config.effort ?? existing?.effort, started: !!(config.resumeId && config.skipCreate), @@ -243,6 +245,7 @@ export class ClaudeCodeSdkProvider implements TransportProvider { state.emittedToolStates.clear(); const resolvedBinary = this.resolveBinaryPath(this.config); + const baseSystemPrompt = extraSystemPrompt ?? state.description; const options: Record = { cwd: state.cwd, ...(state.env ? { env: { ...process.env, ...state.env } } : {}), @@ -252,7 +255,9 @@ export class ClaudeCodeSdkProvider implements TransportProvider { ...(state.started ? { resume: state.resumeId } : { sessionId: state.resumeId }), ...(state.model ? { model: state.model } : {}), ...(state.effort ? { effort: state.effort } : {}), - ...(extraSystemPrompt ? { appendSystemPrompt: extraSystemPrompt } : {}), + ...(baseSystemPrompt ?? state.systemPrompt ? { + appendSystemPrompt: [baseSystemPrompt, state.systemPrompt].filter(Boolean).join('\n\n'), + } : {}), }; // On Windows where claude resolved to a .cmd shim, override the SDK's // internal spawn so we can prepend `node script.js` and avoid spawn diff --git a/src/agent/session-manager.ts b/src/agent/session-manager.ts index b4335443..2dbde482 100644 --- a/src/agent/session-manager.ts +++ b/src/agent/session-manager.ts @@ -862,7 +862,9 @@ export async function relaunchSessionWithSettings( requestedModel: targetRequestedModel ?? undefined, effort: targetEffort ?? undefined, transportConfig: targetTransportConfig ?? undefined, - ccPreset: targetAgentType === 'claude-code' ? (targetCcPreset ?? undefined) : undefined, + ccPreset: (targetAgentType === 'claude-code' || targetAgentType === 'claude-code-sdk') + ? (targetCcPreset ?? undefined) + : undefined, ...(preserveTransportBinding ? { bindExistingKey: record.providerSessionId, skipCreate: true, @@ -1033,9 +1035,14 @@ export async function restoreTransportSessions(providerId: string): Promise | undefined; + let systemPrompt: string | undefined; + let effectiveRequestedModel = effectiveQwenModel; if (s.providerId === 'claude-code-sdk' && s.ccPreset) { - const { resolvePresetEnv } = await import('../daemon/cc-presets.js'); + const { resolvePresetEnv, getPresetTransportOverrides } = await import('../daemon/cc-presets.js'); extraEnv = await resolvePresetEnv(s.ccPreset, s.ccSessionId ?? undefined); + const presetOverrides = await getPresetTransportOverrides(s.ccPreset); + if (!effectiveRequestedModel && presetOverrides.model) effectiveRequestedModel = presetOverrides.model; + systemPrompt = presetOverrides.systemPrompt; } await runtime.initialize({ sessionKey: effectiveSessionKey, @@ -1045,12 +1052,14 @@ export async function restoreTransportSessions(providerId: string): Promise 0 ? { qwenAvailableModels: availableQwenModels } : {}), ...getQwenDisplayMetadata({ - model: effectiveQwenModel, + model: effectiveRequestedModel, authType: qwenRuntime?.authType ?? s.qwenAuthType, authLimit: qwenRuntime?.authLimit ?? s.qwenAuthLimit, quotaUsageLabel: (qwenRuntime?.authType ?? s.qwenAuthType) === 'qwen-oauth' ? getQwenOAuthQuotaUsageLabel() : undefined, @@ -1118,6 +1127,7 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { let qwenAuthLimit: SessionRecord['qwenAuthLimit'] | undefined; let availableQwenModels: string[] | undefined; let sdkDisplay: Pick | undefined; + let transportSystemPrompt: string | undefined; const storedRequestedModel = !opts.fresh ? existing?.requestedModel : undefined; let requestedTransportModel = opts.requestedModel ?? storedRequestedModel ?? (agentType === 'qwen' ? (opts.qwenModel ?? existing?.qwenModel) : undefined); const effectiveTransportConfig = opts.transportConfig ?? existing?.transportConfig ?? {}; @@ -1150,8 +1160,11 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { effectiveSkipCreate = true; } if (opts.ccPreset) { - const { resolvePresetEnv } = await import('../daemon/cc-presets.js'); + const { resolvePresetEnv, getPresetTransportOverrides } = await import('../daemon/cc-presets.js'); transportEnv = { ...(transportEnv ?? {}), ...(await resolvePresetEnv(opts.ccPreset, transportResumeId)) }; + const presetOverrides = await getPresetTransportOverrides(opts.ccPreset); + if (!requestedTransportModel && presetOverrides.model) requestedTransportModel = presetOverrides.model; + transportSystemPrompt = presetOverrides.systemPrompt; } sdkDisplay = await getClaudeSdkRuntimeConfig().catch(() => ({})); } else if (agentType === 'codex-sdk') { @@ -1167,6 +1180,7 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { cwd: projectDir, label: label || name, description, + ...(transportSystemPrompt ? { systemPrompt: transportSystemPrompt } : {}), agentId: requestedTransportModel, bindExistingKey: effectiveBindExistingKey, skipCreate: effectiveSkipCreate, @@ -1214,6 +1228,7 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { ...(sdkDisplay ?? {}), ...(opts.effort ? { effort: opts.effort } : {}), description, + ...(opts.ccPreset ? { ccPreset: opts.ccPreset } : {}), label, parentSession, userCreated: opts.userCreated, diff --git a/src/agent/transport-provider.ts b/src/agent/transport-provider.ts index 641aeb5e..dc479c0d 100644 --- a/src/agent/transport-provider.ts +++ b/src/agent/transport-provider.ts @@ -114,6 +114,8 @@ export interface SessionConfig { label?: string; /** Persona/system prompt injection — used for session description/role. */ description?: string; + /** Runtime/system prompt injection that should not be surfaced as user-facing description. */ + systemPrompt?: string; /** Parent session key for sub-sessions. */ parentSessionKey?: string; /** If binding to an already-existing remote session, use this key directly. */ diff --git a/src/agent/transport-session-runtime.ts b/src/agent/transport-session-runtime.ts index 404a6e8f..2f6c0180 100644 --- a/src/agent/transport-session-runtime.ts +++ b/src/agent/transport-session-runtime.ts @@ -31,6 +31,7 @@ export class TransportSessionRuntime implements SessionRuntime { private _providerSessionId: string | null = null; private _sending = false; private _description: string | undefined; + private _systemPrompt: string | undefined; private _agentId: string | undefined; private _effort: TransportEffortLevel | undefined; private _unsubscribes: Array<() => void> = []; @@ -103,6 +104,7 @@ export class TransportSessionRuntime implements SessionRuntime { /** Set providerSessionId directly (restore from store without initialize). */ setProviderSessionId(id: string): void { this._providerSessionId = id; } setDescription(desc: string): void { this._description = desc; } + setSystemPrompt(prompt: string): void { this._systemPrompt = prompt; } setAgentId(agentId: string): void { this._agentId = agentId; if (this._providerSessionId) { @@ -126,6 +128,7 @@ export class TransportSessionRuntime implements SessionRuntime { async initialize(config: SessionConfig): Promise { this._providerSessionId = await this.provider.createSession(config); this._description = config.description; + this._systemPrompt = config.systemPrompt; this._agentId = config.agentId; this._effort = config.effort; } diff --git a/src/daemon/cc-presets.ts b/src/daemon/cc-presets.ts index 3ccf67cb..7b15a05b 100644 --- a/src/daemon/cc-presets.ts +++ b/src/daemon/cc-presets.ts @@ -71,6 +71,11 @@ export async function resolvePresetEnv(presetName: string, ccSessionId?: string) const preset = await getPreset(presetName); if (!preset) return {}; const env = { ...preset.env }; + // Backward compatibility: older saved presets used ANTHROPIC_AUTH_TOKEN, + // while current Claude CLI/SDK auth reads ANTHROPIC_API_KEY in bare env mode. + if (env['ANTHROPIC_AUTH_TOKEN'] && !env['ANTHROPIC_API_KEY']) { + env['ANTHROPIC_API_KEY'] = env['ANTHROPIC_AUTH_TOKEN']; + } // Auto-fill model aliases from ANTHROPIC_MODEL if (env['ANTHROPIC_MODEL']) { for (const alias of MODEL_ALIASES) { @@ -87,6 +92,30 @@ export async function resolvePresetEnv(presetName: string, ccSessionId?: string) return env; } +export async function getPresetTransportOverrides(presetName: string): Promise<{ + model?: string; + systemPrompt?: string; +}> { + const preset = await getPreset(presetName); + if (!preset) return {}; + const env = await resolvePresetEnv(presetName); + const configuredModel = env['ANTHROPIC_MODEL']?.trim() || undefined; + const configuredBaseUrl = env['ANTHROPIC_BASE_URL']?.trim() || undefined; + const runtimeFacts = [ + `Authoritative runtime fact: this session is using the Claude Code preset "${preset.name}".`, + configuredBaseUrl ? `Authoritative provider endpoint: ${configuredBaseUrl}.` : undefined, + configuredModel ? `Authoritative runtime model: ${configuredModel}.` : undefined, + configuredModel ? `If the user asks which model you are using, answer exactly with "${configuredModel}".` : 'If the user asks which model or provider you are using, answer with the authoritative runtime facts above.', + configuredBaseUrl ? `If the user asks which provider or endpoint you are using, mention "${configuredBaseUrl}".` : undefined, + 'These runtime facts override any generic Claude Code tool schema, enum, or default.', + 'Do not answer with Sonnet, Opus, Haiku, or any inferred Claude default unless that exact value matches the authoritative runtime model above.', + ].filter(Boolean).join(' '); + return { + ...(configuredModel ? { model: configuredModel } : {}), + ...(runtimeFacts ? { systemPrompt: runtimeFacts } : {}), + }; +} + /** Default init message for non-Anthropic providers (no native web search). */ const DEFAULT_INIT_MESSAGE = 'For web searches, use: curl -s "https://html.duckduckgo.com/html/?q=QUERY" | head -200. Replace QUERY with URL-encoded search terms.'; diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index f5aa1391..d24c50ad 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -136,6 +136,25 @@ function getDefaultThinkingLevel(agentType: string | undefined): TransportEffort return supportsEffort(agentType) ? DEFAULT_TRANSPORT_EFFORT : undefined; } +function isTransportIdentityQuestion(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed || trimmed.length > 200) return false; + return /(?:what|which|exact|current).*(?:model|provider|endpoint)|(?:model|provider|endpoint).*(?:using|use|current)|你.*(?:什么|哪个).*(?:模型|provider|服务商|端点)|当前.*(?:模型|provider|服务商|端点)|你在用什么模型/i.test(trimmed); +} + +async function resolveTransportIdentityAnswer(record: SessionRecord): Promise { + let model = record.activeModel ?? record.requestedModel ?? record.modelDisplay ?? undefined; + let provider = record.providerId ?? undefined; + if (record.ccPreset && record.agentType === 'claude-code-sdk') { + const { getPresetTransportOverrides, resolvePresetEnv } = await import('./cc-presets.js'); + const presetOverrides = await getPresetTransportOverrides(record.ccPreset); + const presetEnv = await resolvePresetEnv(record.ccPreset, record.ccSessionId); + model = model ?? presetOverrides.model; + provider = presetEnv['ANTHROPIC_BASE_URL']?.trim() || provider; + } + return model ?? provider ?? null; +} + async function syncSubSessionIfNeeded(sessionName: string, serverLink: ServerLink): Promise { if (!sessionName.startsWith('deck_sub_')) return; const subId = sessionName.slice('deck_sub_'.length); @@ -1235,6 +1254,19 @@ async function handleSend(cmd: Record, serverLink: ServerLink): } catch { /* */ } return; } + if (record && isTransportIdentityQuestion(text)) { + const identityAnswer = await resolveTransportIdentityAnswer(record); + if (identityAnswer) { + emitTransportUserMessage(text); + timelineEmitter.emit(sessionName, 'assistant.text', { text: identityAnswer, streaming: false }, { source: 'daemon', confidence: 'high' }); + const infoStatus = isLegacy ? 'accepted_legacy' : 'accepted'; + timelineEmitter.emit(sessionName, 'command.ack', { commandId: effectiveId, status: infoStatus }); + try { + serverLink.send({ type: 'command.ack', commandId: effectiveId, status: infoStatus, session: sessionName }); + } catch { /* */ } + return; + } + } const modelMatch = text.trim().match(/^\/model\s+(\S+)(?:\s+.*)?$/); const effortMatch = text.trim().match(/^\/(?:thinking|effort)\s+(\S+)\s*$/); if (record?.agentType === 'qwen' && modelMatch) { @@ -1838,6 +1870,7 @@ async function handleSubSessionStart(cmd: Record, serverLink: S const shellBin = cmd.shellBin as string | null | undefined; const ccSessionId = cmd.ccSessionId as string | null | undefined; const parentSession = cmd.parentSession as string | null | undefined; + const ccPreset = cmd.ccPreset as string | null | undefined; const requestedEffort: unknown = cmd.thinking ?? cmd.effort; const effort = isTransportEffortLevel(requestedEffort) ? requestedEffort @@ -1866,6 +1899,7 @@ async function handleSubSessionStart(cmd: Record, serverLink: S requestedModel: (cmd.requestedModel as string | undefined) ?? (cmd.model as string | undefined), transportConfig: (cmd.transportConfig as Record | undefined) ?? undefined, bindExistingKey, + ...(ccPreset ? { ccPreset } : {}), ...(type === 'claude-code-sdk' ? { ccSessionId: randomUUID(), fresh: true } : {}), ...(type === 'codex-sdk' ? { fresh: true } : {}), ...(effort ? { effort } : {}), diff --git a/test/agent/claude-code-sdk-provider.test.ts b/test/agent/claude-code-sdk-provider.test.ts index cfd13f4b..b9844a73 100644 --- a/test/agent/claude-code-sdk-provider.test.ts +++ b/test/agent/claude-code-sdk-provider.test.ts @@ -261,6 +261,29 @@ describe('ClaudeCodeSdkProvider', () => { }); }); + it('passes runtime-only system prompts without polluting description', async () => { + sdkMock.setNextMessages([ + { type: 'system', subtype: 'init', session_id: 'session-system', model: 'claude-sonnet-4-6' }, + { type: 'result', session_id: 'session-system', subtype: 'success', is_error: false, result: 'OK', usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0 } }, + ]); + + const provider = new ClaudeCodeSdkProvider(); + await provider.connect({ binaryPath: 'claude' }); + await provider.createSession({ + sessionKey: 'route-system', + cwd: '/tmp/project', + resumeId: 'session-system', + description: 'Visible description', + systemPrompt: 'Runtime note only', + }); + + await provider.send('route-system', 'hello'); + await flush(); + + const run = sdkMock.runs.at(-1)!; + expect(run.options.appendSystemPrompt).toBe('Visible description\n\nRuntime note only'); + }); + it('emits a fallback streaming delta from assistant text when the SDK does not send text_delta events', async () => { sdkMock.setNextMessages([ { type: 'system', subtype: 'init', session_id: 'session-fallback', model: 'claude-sonnet-4-6' }, diff --git a/test/daemon/cc-presets.test.ts b/test/daemon/cc-presets.test.ts index 3ad82cea..efa066a2 100644 --- a/test/daemon/cc-presets.test.ts +++ b/test/daemon/cc-presets.test.ts @@ -55,6 +55,7 @@ describe('cc presets', () => { await expect(resolvePresetEnv('MiniMax')).resolves.toMatchObject({ ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_API_KEY: 'test-token', ANTHROPIC_MODEL: 'MiniMax-M2.7', ANTHROPIC_SMALL_FAST_MODEL: 'MiniMax-M2.7', ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.7', diff --git a/test/e2e/sdk-transport-flow.test.ts b/test/e2e/sdk-transport-flow.test.ts index 41915b7d..ad5d4db8 100644 --- a/test/e2e/sdk-transport-flow.test.ts +++ b/test/e2e/sdk-transport-flow.test.ts @@ -389,6 +389,48 @@ describe('sdk transport flow e2e', () => { }); }); + it('preserves cc presets when settings restart switches a main session to claude-code-sdk', async () => { + mocks.store.set('deck_settings_preset_brain', { + name: 'deck_settings_preset_brain', + projectName: 'settings_preset', + role: 'brain', + agentType: 'claude-code', + projectDir: '/tmp/settings-preset', + state: 'idle', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + ccSessionId: 'cc-settings-preset', + ccPreset: 'MiniMax', + }); + + const serverLink = { send: vi.fn() } as any; + handleWebCommand({ + type: 'session.restart', + sessionName: 'deck_settings_preset_brain', + agentType: 'claude-code-sdk', + }, serverLink); + await flushAsync(); + await waitForCondition(() => serverLink.send.mock.calls.some((call) => call[0]?.type === 'session_list')); + + const switched = mocks.store.get('deck_settings_preset_brain'); + expect(switched?.agentType).toBe('claude-code-sdk'); + expect(switched?.ccPreset).toBe('MiniMax'); + + handleWebCommand({ type: 'session.send', session: 'deck_settings_preset_brain', text: 'hello', commandId: 'cmd-settings-preset' }, serverLink); + await flushAsync(); + + const claudeCall = mocks.claudeCalls.at(-1); + expect(claudeCall?.options.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: expect.any(String), + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }); + expect(claudeCall?.options.model).toBe('MiniMax-M2.7'); + expect(String(claudeCall?.options.appendSystemPrompt ?? '')).toContain('Authoritative runtime model: MiniMax-M2.7.'); + }); + it('pushes a corrective session_list when settings restart fails', async () => { const tmuxNewSession = newSession as ReturnType; tmuxNewSession.mockRejectedValueOnce(new Error('tmux create failed')); @@ -669,6 +711,103 @@ describe('sdk transport flow e2e', () => { expect(serverLink.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'session.error' })); }); + it('persists and applies cc presets for claude-code-sdk main sessions', async () => { + const serverLink = { send: vi.fn() } as any; + + handleWebCommand({ + type: 'session.start', + project: 'ccsdk minimax', + dir: '/tmp/ccsdk-minimax-e2e', + agentType: 'claude-code-sdk', + ccPreset: 'MiniMax', + }, serverLink); + await flushAsync(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const sessionName = 'deck_ccsdk_minimax_brain'; + const record = mocks.store.get(sessionName); + expect(record?.ccPreset).toBe('MiniMax'); + + handleWebCommand({ type: 'session.send', session: sessionName, text: 'hello', commandId: 'cmd-ccsdk-minimax' }, serverLink); + await flushAsync(); + + const claudeCall = mocks.claudeCalls.at(-1); + expect(claudeCall?.options.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: expect.any(String), + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }); + expect(claudeCall?.options.model).toBe('MiniMax-M2.7'); + expect(String(claudeCall?.options.appendSystemPrompt ?? '')).toContain('Authoritative runtime model: MiniMax-M2.7.'); + }); + + it('persists and applies cc presets for claude-code-sdk sub-sessions', async () => { + const serverLink = { send: vi.fn() } as any; + + handleWebCommand({ + type: 'subsession.start', + id: 'ccsdk_minimax_sub', + sessionType: 'claude-code-sdk', + cwd: '/tmp/ccsdk-minimax-sub-e2e', + parentSession: 'deck_parent_brain', + ccPreset: 'MiniMax', + }, serverLink); + await flushAsync(); + await waitForCondition(() => serverLink.send.mock.calls.length > 0); + + const sessionName = 'deck_sub_ccsdk_minimax_sub'; + const record = mocks.store.get(sessionName); + expect(record?.ccPreset).toBe('MiniMax'); + expect(serverLink.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'subsession.sync', + id: 'ccsdk_minimax_sub', + ccPresetId: 'MiniMax', + })); + + handleWebCommand({ type: 'session.send', session: sessionName, text: 'hello', commandId: 'cmd-ccsdk-minimax-sub' }, serverLink); + await flushAsync(); + + const claudeCall = mocks.claudeCalls.at(-1); + expect(claudeCall?.options.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_API_KEY: expect.any(String), + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }); + expect(claudeCall?.options.model).toBe('MiniMax-M2.7'); + expect(String(claudeCall?.options.appendSystemPrompt ?? '')).toContain('Authoritative runtime model: MiniMax-M2.7.'); + }); + + it('answers claude-code-sdk runtime identity questions from authoritative preset metadata', async () => { + const serverLink = { send: vi.fn() } as any; + + handleWebCommand({ + type: 'session.start', + project: 'ccsdk identity', + dir: '/tmp/ccsdk-identity-e2e', + agentType: 'claude-code-sdk', + ccPreset: 'MiniMax', + }, serverLink); + await flushAsync(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + mocks.claudeCalls.length = 0; + const sessionName = 'deck_ccsdk_identity_brain'; + handleWebCommand({ + type: 'session.send', + session: sessionName, + text: 'Reply with only the exact model or provider you are currently using.', + commandId: 'cmd-ccsdk-identity', + }, serverLink); + await flushAsync(); + + expect(mocks.claudeCalls).toEqual([]); + expect(mocks.emitted).toContainEqual(expect.objectContaining({ + session: sessionName, + type: 'assistant.text', + payload: expect.objectContaining({ text: 'MiniMax-M2.7', streaming: false }), + })); + }); + beforeEach(() => { mocks.store.clear(); mocks.emitted.length = 0; From 40ea144f1977d827845e62c532b7d43f1cd8a6d7 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 8 Apr 2026 22:55:10 +0800 Subject: [PATCH 02/50] fix(ci): remove duplicate ccPreset declaration --- src/daemon/command-handler.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index d24c50ad..f8ec7ff7 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -1915,8 +1915,6 @@ async function handleSubSessionStart(cmd: Record, serverLink: S } return; } - - const ccPreset = cmd.ccPreset as string | null | undefined; const subCcInitPrompt = cmd.ccInitPrompt as string | null | undefined; const description = cmd.description as string | null | undefined; From 2f622ed5cdaece2f94db4a55f025699fbb07e4a9 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 8 Apr 2026 23:00:20 +0800 Subject: [PATCH 03/50] Throttle transport typewriter updates --- src/daemon/transport-relay.ts | 77 +++++++++++++- test/daemon/transport-relay.test.ts | 143 +++++++++++++++++++++++--- web/src/components/ChatView.tsx | 82 +++++++++++---- web/test/components/ChatView.test.tsx | 50 ++++++++- 4 files changed, 309 insertions(+), 43 deletions(-) diff --git a/src/daemon/transport-relay.ts b/src/daemon/transport-relay.ts index 73cb5889..57f94495 100644 --- a/src/daemon/transport-relay.ts +++ b/src/daemon/transport-relay.ts @@ -16,6 +16,72 @@ import { resolveContextWindow } from '../util/model-context.js'; let sendToServer: ((msg: Record) => void) | null = null; const inFlightMessages = new Map(); +const pendingStreamUpdates = new Map | null; +}>(); +const STREAM_UPDATE_INTERVAL_MS = 200; + +function emitStreamingAssistantText(sessionName: string, eventId: string, text: string): void { + timelineEmitter.emit(sessionName, 'assistant.text', { + text, + streaming: true, + }, { source: 'daemon', confidence: 'high', eventId }); +} + +function clearPendingStreamUpdate(eventId: string): void { + const pending = pendingStreamUpdates.get(eventId); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + pendingStreamUpdates.delete(eventId); +} + +function flushPendingStreamUpdate(eventId: string): void { + const pending = pendingStreamUpdates.get(eventId); + if (!pending || pending.pendingText == null) return; + pending.timer = null; + pending.lastEmitAt = Date.now(); + const nextText = pending.pendingText; + pending.pendingText = null; + emitStreamingAssistantText(pending.sessionName, pending.eventId, nextText); +} + +function emitThrottledStreamingAssistantText(sessionName: string, eventId: string, text: string): void { + const now = Date.now(); + let pending = pendingStreamUpdates.get(eventId); + if (!pending) { + pending = { + sessionName, + eventId, + lastEmitAt: 0, + pendingText: null, + timer: null, + }; + pendingStreamUpdates.set(eventId, pending); + } else { + pending.sessionName = sessionName; + } + + if (pending.lastEmitAt === 0 || now - pending.lastEmitAt >= STREAM_UPDATE_INTERVAL_MS) { + if (pending.timer) { + clearTimeout(pending.timer); + pending.timer = null; + } + pending.pendingText = null; + pending.lastEmitAt = now; + emitStreamingAssistantText(sessionName, eventId, text); + return; + } + + pending.pendingText = text; + if (pending.timer) return; + pending.timer = setTimeout(() => { + flushPendingStreamUpdate(eventId); + }, STREAM_UPDATE_INTERVAL_MS - (now - pending.lastEmitAt)); +} /** Set the send function (called once during server-link setup) */ export function setTransportRelaySend(fn: (msg: Record) => void): void { @@ -34,16 +100,17 @@ export function wireProviderToRelay(provider: TransportProvider): void { // Use delta.delta as the display text directly — the provider's internal // accumulator handles cumulative vs incremental differences. const stableEventId = `transport:${sessionName}:${delta.messageId}`; + const previous = inFlightMessages.get(sessionName); + if (previous && previous.messageId !== delta.messageId) { + clearPendingStreamUpdate(previous.eventId); + } inFlightMessages.set(sessionName, { messageId: delta.messageId, eventId: stableEventId, text: delta.delta, }); - timelineEmitter.emit(sessionName, 'assistant.text', { - text: delta.delta, - streaming: true, - }, { source: 'daemon', confidence: 'high', eventId: stableEventId }); + emitThrottledStreamingAssistantText(sessionName, stableEventId, delta.delta); }); provider.onComplete((providerSid: string, message: AgentMessage) => { @@ -60,6 +127,7 @@ export function wireProviderToRelay(provider: TransportProvider): void { ? tracked.eventId : `transport:${sessionName}:${message.id}`; inFlightMessages.delete(sessionName); + clearPendingStreamUpdate(stableEventId); timelineEmitter.emit(sessionName, 'assistant.text', { text: finalText, streaming: false, @@ -102,6 +170,7 @@ export function wireProviderToRelay(provider: TransportProvider): void { const tracked = inFlightMessages.get(sessionName); inFlightMessages.delete(sessionName); + if (tracked) clearPendingStreamUpdate(tracked.eventId); const errorText = tracked?.text ? `${tracked.text}\n\n⚠️ Error: ${error.message}` : `⚠️ Error: ${error.message}`; diff --git a/test/daemon/transport-relay.test.ts b/test/daemon/transport-relay.test.ts index 0ec27cdd..adf2ae24 100644 --- a/test/daemon/transport-relay.test.ts +++ b/test/daemon/transport-relay.test.ts @@ -140,33 +140,74 @@ describe('transport-relay (timeline-emitter based)', () => { expect(opts.eventId).toBe('transport:sess-a:msg-abc'); }); - it('multiple deltas pass through directly (provider handles accumulation)', () => { + it('throttles streaming updates to at most one emit every 200ms and keeps the latest text', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-a', makeDelta({ messageId: 'msg-throttle', delta: 'a' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-throttle', delta: 'ab' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-throttle', delta: 'abc' })); + + expect(emitMock).toHaveBeenCalledTimes(1); + expect(emitMock.mock.calls[0][2].text).toBe('a'); + + vi.advanceTimersByTime(199); + expect(emitMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(emitMock).toHaveBeenCalledTimes(2); + expect(emitMock.mock.calls[1][2].text).toBe('abc'); + + vi.useRealTimers(); + }); + + it('does not let a new message flush an old throttled delta later', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + const { provider, fireDelta } = makeMockProvider(); wireProviderToRelay(provider); - fireDelta('sess-a', makeDelta({ messageId: 'msg-1', delta: 'foo ' })); - fireDelta('sess-a', makeDelta({ messageId: 'msg-1', delta: 'bar ' })); - fireDelta('sess-a', makeDelta({ messageId: 'msg-1', delta: 'baz' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-old', delta: 'old-a' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-old', delta: 'old-ab' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-new', delta: 'new-a' })); + + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(2); + expect(emitMock.mock.calls[0][2].text).toBe('old-a'); + expect(emitMock.mock.calls[1][2].text).toBe('new-a'); - expect(emitMock).toHaveBeenCalledTimes(3); + vi.advanceTimersByTime(500); + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(2); + expect(textCalls.map(c => c[2].text)).not.toContain('old-ab'); - // Each delta is passed through directly — provider already accumulated - expect(emitMock.mock.calls[0][2].text).toBe('foo '); - expect(emitMock.mock.calls[1][2].text).toBe('bar '); - expect(emitMock.mock.calls[2][2].text).toBe('baz'); + vi.useRealTimers(); }); - it('uses the same stable eventId across multiple deltas for the same message', () => { + it('throttles independently per session', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + const { provider, fireDelta } = makeMockProvider(); wireProviderToRelay(provider); - fireDelta('sess-a', makeDelta({ messageId: 'msg-stable', delta: 'a' })); - fireDelta('sess-a', makeDelta({ messageId: 'msg-stable', delta: 'b' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A1' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A2' })); + fireDelta('sess-b', makeDelta({ messageId: 'msg-b', delta: 'B1' })); + fireDelta('sess-b', makeDelta({ messageId: 'msg-b', delta: 'B2' })); + + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(2); + vi.advanceTimersByTime(200); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(4); + expect(textCalls.filter(c => c[0] === 'sess-a').map(c => c[2].text)).toEqual(['A1', 'A2']); + expect(textCalls.filter(c => c[0] === 'sess-b').map(c => c[2].text)).toEqual(['B1', 'B2']); - const id1 = emitMock.mock.calls[0][3].eventId; - const id2 = emitMock.mock.calls[1][3].eventId; - expect(id1).toBe('transport:sess-a:msg-stable'); - expect(id2).toBe('transport:sess-a:msg-stable'); + vi.useRealTimers(); }); it('does NOT cache to JSONL via appendTransportEvent', () => { @@ -286,6 +327,52 @@ describe('transport-relay (timeline-emitter based)', () => { expect(textCall![3].eventId).toBe('transport:sess-1:msg-4'); }); + it('emits final completion immediately even when a throttled delta is pending', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta, fireComplete } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-1', makeDelta({ messageId: 'msg-final', delta: 'a' })); + fireDelta('sess-1', makeDelta({ messageId: 'msg-final', delta: 'ab' })); + emitMock.mockClear(); + + fireComplete('sess-1', makeMessage({ id: 'msg-final', content: 'final answer' })); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][2].text).toBe('final answer'); + expect(textCalls[0][2].streaming).toBe(false); + + vi.advanceTimersByTime(500); + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(1); + + vi.useRealTimers(); + }); + + it('completion still emits immediately when another session has a pending throttled delta', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta, fireComplete } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A1' })); + fireDelta('sess-a', makeDelta({ messageId: 'msg-a', delta: 'A2' })); + emitMock.mockClear(); + + fireComplete('sess-b', makeMessage({ id: 'msg-b', sessionId: 'sess-b', content: 'done-b' })); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe('sess-b'); + expect(textCalls[0][2].text).toBe('done-b'); + expect(textCalls[0][2].streaming).toBe(false); + + vi.useRealTimers(); + }); + it('caches to JSONL via appendTransportEvent with type assistant.text', async () => { const { provider, fireComplete } = makeMockProvider(); wireProviderToRelay(provider); @@ -416,6 +503,30 @@ describe('transport-relay (timeline-emitter based)', () => { expect(textCall?.[2]?.streaming).toBe(false); expect(textCall?.[2]?.text).toBe('partial answer\n\n⚠️ Error: boom'); }); + + it('emits error immediately even when a throttled delta is pending and suppresses the delayed flush', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z')); + + const { provider, fireDelta, fireError } = makeMockProvider(); + wireProviderToRelay(provider); + + fireDelta('sess-err', makeDelta({ messageId: 'msg-err-2', delta: 'partial' })); + fireDelta('sess-err', makeDelta({ messageId: 'msg-err-2', delta: 'partial+' })); + emitMock.mockClear(); + + fireError('sess-err', { code: 'PROVIDER_ERROR', message: 'boom-now', recoverable: true }); + + const textCalls = emitMock.mock.calls.filter(c => c[1] === 'assistant.text'); + expect(textCalls).toHaveLength(1); + expect(textCalls[0][2].text).toBe('partial+\n\n⚠️ Error: boom-now'); + expect(textCalls[0][2].streaming).toBe(false); + + vi.advanceTimersByTime(500); + expect(emitMock.mock.calls.filter(c => c[1] === 'assistant.text')).toHaveLength(1); + + vi.useRealTimers(); + }); }); // ── emitTransportUserMessage ───────────────────────────────────────────── diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 9d9e7c8e..a68046d6 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -55,6 +55,14 @@ interface ViewItem { lastTs?: number; } +interface AssistantBlockProps { + text: string; + ts: number; + onPathClick?: (p: string) => void; + onUrlClick?: (url: string) => void; + onDownload?: (path: string) => void; +} + const TOOL_INPUT_SUMMARY_KEYS = [ 'query', 'command', @@ -398,6 +406,33 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde document.addEventListener('mouseup', onUp); }, [sessionId]); + const handlePathClick = useCallback((path: string) => { + setFileBrowserPath(path.replace(/^`+|`+$/g, '')); + }, []); + + const handleUrlClick = useCallback((url: string) => { + setPendingUrl(url); + }, []); + + const handleDownload = useCallback((path: string) => { + if (!serverId || !ws) return; + const reqId = ws.fsReadFile(path); + const unsub = ws.onMessage((msg) => { + if (msg.type !== 'fs.read_response' || msg.requestId !== reqId) return; + unsub(); + if (msg.downloadId) { + import('../api.js').then(({ downloadAttachment }) => { + downloadAttachment(serverId, msg.downloadId as string).catch(() => {}); + }); + } + }); + setTimeout(unsub, 30_000); + }, [serverId, ws]); + + const pathClickHandler = ws && !preview ? handlePathClick : undefined; + const urlClickHandler = !preview ? handleUrlClick : undefined; + const downloadHandler = serverId && ws ? handleDownload : undefined; + const viewItems = useMemo(() => buildViewItems(events), [events]); const scrollToBottom = () => { @@ -763,31 +798,19 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde {!loading && viewItems.map((item, idx) => { const nextItem = viewItems[idx + 1]; const nextTs = nextItem?.ts ?? nextItem?.event?.ts; - const onPathClick = ws && !preview ? (p: string) => setFileBrowserPath(p.replace(/^`+|`+$/g, '')) : undefined; - const onUrlClick = !preview ? (url: string) => setPendingUrl(url) : undefined; - const onDownload = serverId && ws ? (p: string) => { - const reqId = ws.fsReadFile(p); - const unsub = ws.onMessage((msg) => { - if (msg.type !== 'fs.read_response' || msg.requestId !== reqId) return; - unsub(); - if (msg.downloadId) { - import('../api.js').then(({ downloadAttachment }) => { - downloadAttachment(serverId!, msg.downloadId as string).catch(() => {}); - }); - } - }); - // Auto-cleanup after 30s - setTimeout(unsub, 30_000); - } : undefined; return item.type === 'assistant-block' ? ( -
- - -
+ ) : item.type === 'tool-group' ? ( - + ) : ( - + ); })} {!loading &&
} @@ -1022,6 +1045,21 @@ function ToolCallGroup({ events, onPathClick }: { events: TimelineEvent[]; onPat // ToolInputFold removed — replaced by unified ToolBlockFold (CSS max-height based) +const AssistantBlock = memo(function AssistantBlock({ + text, + ts, + onPathClick, + onUrlClick, + onDownload, +}: AssistantBlockProps) { + return ( +
+ + +
+ ); +}); + function AttachmentDownloadButton({ att, serverId, onPathClick }: { att: { id: string; originalName?: string; size?: number; daemonPath?: string }; serverId: string; onPathClick?: (p: string) => void }) { const { t } = useTranslation(); const [error, setError] = useState(null); diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx index 7a111b8c..c8980683 100644 --- a/web/test/components/ChatView.test.tsx +++ b/web/test/components/ChatView.test.tsx @@ -6,6 +6,8 @@ import { render, waitFor, cleanup, fireEvent } from '@testing-library/preact'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import { ChatView } from '../../src/components/ChatView.js'; +const chatMarkdownRenderSpy = vi.hoisted(() => vi.fn()); + type ViewportListener = () => void; const visualViewportListeners = new Map>(); const visualViewportMock = { @@ -30,7 +32,10 @@ vi.mock('react-i18next', () => ({ })); vi.mock('../../src/components/ChatMarkdown.js', () => ({ - ChatMarkdown: ({ text }: { text: string }) =>
{text}
, + ChatMarkdown: ({ text }: { text: string }) => { + chatMarkdownRenderSpy(text); + return
{text}
; + }, })); vi.mock('../../src/components/FileBrowser.js', () => ({ @@ -48,6 +53,7 @@ describe('ChatView', () => { afterEach(() => { cleanup(); + chatMarkdownRenderSpy.mockClear(); visualViewportMock.height = 800; visualViewportListeners.clear(); }); @@ -158,6 +164,48 @@ describe('ChatView', () => { }); }); + it('does not rerender an unchanged assistant block when the parent chat rerenders', async () => { + const { rerender } = render( + , + ); + + expect(chatMarkdownRenderSpy.mock.calls.filter(([text]) => text === 'stable block')).toHaveLength(1); + + rerender( + , + ); + + expect(chatMarkdownRenderSpy.mock.calls.filter(([text]) => text === 'stable block')).toHaveLength(1); + }); + it('restores mobile keyboard scroll position from bottom offset instead of snapping to top', async () => { const initialEvents = [ { From cf304df6cad651b56385528455e5774f96b79fd1 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 8 Apr 2026 23:13:54 +0800 Subject: [PATCH 04/50] fix(test): align transport streaming assertions with throttling --- test/daemon/oc-streaming-integration.test.ts | 15 +++++++++++++++ test/e2e/sdk-transport-flow.test.ts | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/test/daemon/oc-streaming-integration.test.ts b/test/daemon/oc-streaming-integration.test.ts index e4f13cbb..d7f7b0cd 100644 --- a/test/daemon/oc-streaming-integration.test.ts +++ b/test/daemon/oc-streaming-integration.test.ts @@ -74,6 +74,10 @@ function emitAgentEvent(payload: Record): void { lastWs().emit('message', JSON.stringify({ type: 'event', event: 'agent', payload })); } +function advanceStreamWindow(): void { + vi.advanceTimersByTime(250); +} + // ── Tests ──────────────────────────────────────────────────────────────────── describe('OC streaming integration: provider → relay → emitter', () => { @@ -105,9 +109,13 @@ describe('OC streaming integration: provider → relay → emitter', () => { emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'start' }, key: 'test:sess' }); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '你' }, key: 'test:sess' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '好' }, key: 'test:sess' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '世' }, key: 'test:sess' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: '界' }, key: 'test:sess' }); + advanceStreamWindow(); // Filter to assistant.text events for the sanitized session const textEvents = emittedEvents.filter( @@ -135,7 +143,9 @@ describe('OC streaming integration: provider → relay → emitter', () => { emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'start' }, key: 'test:s2' }); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'Hello ' }, key: 'test:s2' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'World' }, key: 'test:s2' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'end' }, key: 'test:s2' }); const textEvents = emittedEvents.filter( @@ -161,7 +171,9 @@ describe('OC streaming integration: provider → relay → emitter', () => { // OC sends text=delta (both incremental, text field NOT cumulative) emitAgentEvent({ runId, stream: 'assistant', data: { text: '收到', delta: '收到' }, key: 'test:nc' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { text: '主人', delta: '主人' }, key: 'test:nc' }); + advanceStreamWindow(); const textEvents = emittedEvents.filter( (e) => e.type === 'assistant.text' && e.sessionId === 'test___nc', @@ -203,8 +215,11 @@ describe('OC streaming integration: provider → relay → emitter', () => { emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'start' }, key: 'test:dd' }); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'A' }, key: 'test:dd' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'B' }, key: 'test:dd' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'assistant', data: { delta: 'C' }, key: 'test:dd' }); + advanceStreamWindow(); emitAgentEvent({ runId, stream: 'lifecycle', data: { phase: 'end' }, key: 'test:dd' }); const textEvents = emittedEvents.filter( diff --git a/test/e2e/sdk-transport-flow.test.ts b/test/e2e/sdk-transport-flow.test.ts index ad5d4db8..5010e30e 100644 --- a/test/e2e/sdk-transport-flow.test.ts +++ b/test/e2e/sdk-transport-flow.test.ts @@ -848,9 +848,8 @@ describe('sdk transport flow e2e', () => { const toolResult = mocks.emitted.find((e) => e.session === SESSION_CC && e.type === 'tool.result'); const claudeCall = mocks.claudeCalls.at(-1); - expect(streaming.map((e) => e.payload.text)).toEqual(['Claude', 'Claude: hello']); + expect(streaming.map((e) => e.payload.text)).toEqual(['Claude']); expect(streaming[0]?.opts?.eventId).toBe(stableEventId); - expect(streaming[1]?.opts?.eventId).toBe(stableEventId); expect(final?.payload.text).toBe('Claude: hello'); expect(final?.opts?.eventId).toBe(stableEventId); expect(usage?.payload.model).toBe('claude-sonnet-4-6'); @@ -887,9 +886,8 @@ describe('sdk transport flow e2e', () => { const toolResult = mocks.emitted.find((e) => e.session === SESSION_CX && e.type === 'tool.result'); const ack = mocks.emitted.find((e) => e.session === SESSION_CX && e.type === 'command.ack'); - expect(streaming.map((e) => e.payload.text)).toEqual(['Codex', 'Codex: hello']); + expect(streaming.map((e) => e.payload.text)).toEqual(['Codex']); expect(streaming[0]?.opts?.eventId).toBe(`transport:${SESSION_CX}:msg-codex-e2e`); - expect(streaming[1]?.opts?.eventId).toBe(`transport:${SESSION_CX}:msg-codex-e2e`); expect(final?.payload.text).toBe('Codex: hello'); expect(final?.opts?.eventId).toBe(`transport:${SESSION_CX}:msg-codex-e2e`); expect(usage?.payload.inputTokens).toBe(7); From ca8de2e47b5247136c2021da985e6fc0defce3a5 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 8 Apr 2026 23:22:13 +0800 Subject: [PATCH 05/50] fix(test): align qwen transport streaming assertions --- test/e2e/qwen-transport-flow.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/e2e/qwen-transport-flow.test.ts b/test/e2e/qwen-transport-flow.test.ts index 3bf5a89b..cfad5e06 100644 --- a/test/e2e/qwen-transport-flow.test.ts +++ b/test/e2e/qwen-transport-flow.test.ts @@ -215,9 +215,8 @@ describe('qwen transport flow e2e', () => { expect(user?.payload.text).toBe('hello'); expect(running).toBeDefined(); expect(thinking?.payload.text).toBe(''); - expect(streaming.map((e) => e.payload.text)).toEqual(['Qwen', 'Qwen: hello']); + expect(streaming.map((e) => e.payload.text)).toEqual(['Qwen']); expect(streaming[0]?.opts?.eventId).toBe(stableEventId); - expect(streaming[1]?.opts?.eventId).toBe(stableEventId); expect(final?.payload.text).toBe('Qwen: hello'); expect(final?.opts?.eventId).toBe(stableEventId); const usage = mocks.emitted.find((e) => e.session === SESSION && e.type === 'usage.update'); From 7a228542c4a72f2c6efe66b2bc240498ccbcd817 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 8 Apr 2026 23:34:25 +0800 Subject: [PATCH 06/50] fix(test): stabilize transport preset e2e coverage --- test/e2e/sdk-transport-flow.test.ts | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/e2e/sdk-transport-flow.test.ts b/test/e2e/sdk-transport-flow.test.ts index 5010e30e..f6d16c35 100644 --- a/test/e2e/sdk-transport-flow.test.ts +++ b/test/e2e/sdk-transport-flow.test.ts @@ -24,6 +24,39 @@ const mocks = vi.hoisted(() => { return { store, emitted, claudeCalls, codexCalls }; }); +const PRESET_ENV = { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_API_KEY: 'test-token', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + ANTHROPIC_SMALL_FAST_MODEL: 'MiniMax-M2.7', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'MiniMax-M2.7', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'MiniMax-M2.7', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'MiniMax-M2.7', + IMCODES_CONTEXT_WINDOW: '200000', +}; + +vi.mock('../../src/daemon/cc-presets.js', () => ({ + getPreset: vi.fn(async (name: string) => ( + name.trim().toLowerCase() === 'minimax' + ? { name: 'minimax', env: PRESET_ENV, contextWindow: 200000 } + : undefined + )), + resolvePresetEnv: vi.fn(async (name: string) => ( + name.trim().toLowerCase() === 'minimax' ? { ...PRESET_ENV } : {} + )), + getPresetTransportOverrides: vi.fn(async (name: string) => ( + name.trim().toLowerCase() === 'minimax' + ? { + model: 'MiniMax-M2.7', + systemPrompt: 'Authoritative runtime model: MiniMax-M2.7.', + } + : {} + )), + getPresetInitMessage: vi.fn(() => 'preset-init'), + invalidateCache: vi.fn(), +})); + vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); const { EventEmitter } = await import('node:events'); From 3ee077f72849f9e7afc996caf19df3114cc715ad Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Thu, 9 Apr 2026 00:12:10 +0800 Subject: [PATCH 07/50] Keep mobile openspec menus onscreen --- web/src/components/SessionControls.tsx | 48 ++++- web/src/styles.css | 4 +- web/test/components/SessionControls.test.tsx | 185 +++++++++++++++++++ 3 files changed, 234 insertions(+), 3 deletions(-) diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 02759661..4381496c 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -664,6 +664,38 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on [openSpecLayoutTick, openSpecOpen], ); + const openSpecAuditDropdownStyle = isOpenSpecMobile + ? { + position: 'fixed', + left: 8, + right: 8, + bottom: 72, + minWidth: 0, + width: 'auto', + maxWidth: 'none', + } as const + : { + right: 0, + bottom: 'calc(100% + 6px)', + minWidth: 180, + } as const; + + const openSpecProposeDropdownStyle = isOpenSpecMobile + ? { + position: 'fixed', + left: 8, + right: 8, + bottom: 72, + minWidth: 0, + width: 'auto', + maxWidth: 'none', + } as const + : { + right: 0, + bottom: 'calc(100% + 6px)', + minWidth: 220, + } as const; + useEffect(() => { if (!openSpecOpen || typeof window === 'undefined') return; const refreshLayout = () => setOpenSpecLayoutTick((tick) => tick + 1); @@ -795,6 +827,12 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on extra.p2pRounds = selection.rounds; if (selection.config.extraPrompt) extra.p2pExtraPrompt = selection.config.extraPrompt; if (selection.config.hopTimeoutMinutes != null) extra.p2pHopTimeoutMs = Math.min(selection.config.hopTimeoutMinutes * 60_000, 600_000); + if (mode === P2P_CONFIG_MODE) { + if (selection.config.advancedPresetKey) extra.p2pAdvancedPresetKey = selection.config.advancedPresetKey; + if (selection.config.advancedRounds) extra.p2pAdvancedRounds = selection.config.advancedRounds; + if (selection.config.advancedRunTimeoutMinutes != null) extra.p2pAdvancedRunTimeoutMinutes = selection.config.advancedRunTimeoutMinutes; + if (selection.config.contextReducer) extra.p2pContextReducer = selection.config.contextReducer; + } }, [p2pSavedConfig]); const buildSendPayload = useCallback((options?: string | BuildSendPayloadOptions): PendingSendPayload | null => { @@ -836,6 +874,12 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on extra.p2pRounds = override?.rounds ?? cfg.rounds ?? 1; if (cfg.extraPrompt) extra.p2pExtraPrompt = cfg.extraPrompt; if (cfg.hopTimeoutMinutes != null) extra.p2pHopTimeoutMs = Math.min(cfg.hopTimeoutMinutes * 60_000, 600_000); + if (!override?.modeOverride || override.modeOverride === P2P_CONFIG_MODE) { + if (cfg.advancedPresetKey) extra.p2pAdvancedPresetKey = cfg.advancedPresetKey; + if (cfg.advancedRounds) extra.p2pAdvancedRounds = cfg.advancedRounds; + if (cfg.advancedRunTimeoutMinutes != null) extra.p2pAdvancedRunTimeoutMinutes = cfg.advancedRunTimeoutMinutes; + if (cfg.contextReducer) extra.p2pContextReducer = cfg.contextReducer; + } } // For non-config mode overrides (single or combo), send as p2pMode so the daemon uses it if (override?.modeOverride && override.modeOverride !== 'config') { @@ -1407,7 +1451,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on {t('openspec.audit_action')} {openSpecAuditMenu === changeName && ( -