Skip to content

fix(daemon): language switch writes to wrong output-language.md path#4938

Merged
chiga0 merged 10 commits into
daemon_mode_b_mainfrom
fix/language-switch-path-mismatch
Jun 11, 2026
Merged

fix(daemon): language switch writes to wrong output-language.md path#4938
chiga0 merged 10 commits into
daemon_mode_b_mainfrom
fix/language-switch-path-mismatch

Conversation

@chiga0

@chiga0 chiga0 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a bug in POST /session/:id/language (introduced in #4705) where the language switch silently fails because the output-language file is written to the wrong path.

Root cause: updateOutputLanguageFile() always writes to the global ~/.qwen/output-language.md, but Config.outputLanguageFilePath may point to a project-level <cwd>/.qwen/output-language.md (when it existed at daemon startup). Since refreshHierarchicalMemory reads from the Config-bound path, the written file is never read back — the language switch has no effect.

Additional issue: On a fresh environment where no output-language.md exists, the first language switch creates the file at the global path, but Config.outputLanguageFilePath remains undefined (was readonly), so refreshHierarchicalMemory never discovers the newly created file.

Changes

Bug fixes

  • Config.outputLanguageFilePath: remove readonly, add setOutputLanguageFilePath() setter so the path can be registered after first-time file creation
  • languageUtils.ts: add optional targetPath parameter to writeOutputLanguageFile() and updateOutputLanguageFile(), export getOutputLanguageFilePath() for callers that need the global default
  • acpAgent.ts: write to the session Config's actual path; on first-time creation register the path on Config; on multi-session refresh also update each session's own file if its path differs
  • languageCommand.ts and SettingsDialog.tsx: same Config-bound path fix for the CLI /language command and settings dialog

Enhancements

  • server.ts: expose supportedLanguages array in GET /capabilities response so clients can discover valid language codes before calling the endpoint
  • SDK: add DaemonClient.setSessionLanguage() method and SetSessionLanguageResult type

How it ensures read/write consistency

Scenario Write path Read path Consistent
Project-level file exists config.getOutputLanguageFilePath() = project path Same field
Only global file exists config.getOutputLanguageFilePath() = global path Same field
First-time creation Fallback writes global → setOutputLanguageFilePath() registers it Registered field

Test plan

  • npx vitest run packages/cli/src/ui/commands/languageCommand.test.ts — 48 tests pass
  • npx vitest run packages/cli/src/serve/server.test.ts -t "language" — 7 language route tests pass
  • Local daemon startup verification: GET /capabilities returns supportedLanguages, POST /session/:id/language validates against the list
  • Deploy to environment with project-level .qwen/output-language.md and verify language switch takes effect

Related

🤖 Generated with Qwen Code

## Problem

`POST /session/:id/language` (PR #4705) always writes `output-language.md`
to the global `~/.qwen/` path, but `Config.outputLanguageFilePath` may
point to a project-level `<cwd>/.qwen/output-language.md` (when it existed
at startup). Since `refreshHierarchicalMemory` reads from the Config-bound
path, the language switch silently fails when a project-level file exists.

Additionally, on a fresh environment where no `output-language.md` exists,
the first language switch creates the file but `Config.outputLanguageFilePath`
remains `undefined` (readonly), so `refreshHierarchicalMemory` never reads
the newly created file.

## Fix

1. **Config.outputLanguageFilePath**: remove `readonly`, add
   `setOutputLanguageFilePath()` so the path can be registered after
   first-time file creation.

2. **languageUtils.ts**: add optional `targetPath` parameter to
   `writeOutputLanguageFile()` and `updateOutputLanguageFile()`. Export
   `getOutputLanguageFilePath()` for callers that need the global default.

3. **acpAgent.ts**: write to the session Config's actual path. On first-time
   creation (path was undefined), register the global path on Config. On
   multi-session refresh, also update each session's own file if its path
   differs from the one already written.

4. **languageCommand.ts** and **SettingsDialog.tsx**: same Config-bound
   path fix for the CLI `/language` command and settings dialog.

5. **server.ts**: expose `supportedLanguages` array in `GET /capabilities`
   so clients can discover valid language codes before calling the endpoint.

6. **SDK**: add `DaemonClient.setSessionLanguage()` method and
   `SetSessionLanguageResult` type.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @chiga0, thanks for this PR — the bug fix itself looks well-targeted and the description is thorough.

However, the PR body doesn't follow our PR template. A few headings are renamed and some required sections are missing, which makes it harder for reviewers to find what they need. Could you reformat to match?

What's there but with wrong headings:

  • ## Summary → should be ## What this PR does
  • ## Changes / ## How it ensures read/write consistency → should be ## Why it's needed
  • ## Test plan → should be ## Reviewer Test Plan with ### How to verify and ### Evidence (Before & After) subsections
  • ## Related → should be ## Linked Issues

Missing entirely:

  • ## Risk & Scope (risk/tradeoff, out of scope, breaking changes)
  • ### Tested on table (macOS / Windows / Linux status)
  • <details>中文说明</details> — the bottom block is empty

Once the template is in place, happy to dig into the code. 🔍

中文说明

感谢贡献!Bug 修复方向很好,描述也很详细。

但 PR 正文没有按照 PR 模板 的格式来写。有些标题名称不对,还有一些必填章节缺失,这会让 reviewer 难以快速定位信息。能否按模板重新整理一下?

标题不对的部分:

  • ## Summary → 应为 ## What this PR does
  • ## Changes / ## How it ensures read/write consistency → 应为 ## Why it's needed
  • ## Test plan → 应为 ## Reviewer Test Plan,包含 ### How to verify### Evidence (Before & After) 子章节
  • ## Related → 应为 ## Linked Issues

完全缺失的部分:

  • ## Risk & Scope(风险/取舍、不在范围内的、破坏性变更)
  • ### Tested on 表格(macOS / Windows / Linux 测试状态)
  • <details>中文说明</details> — 底部中文翻译区块是空的

模板格式调整后,我们会继续看代码。🔍

workspaceCwd: boundWorkspace,
// Active mediation policy under the `policy` namespace.
policy: { permission: bridge.permissionPolicy },
supportedLanguages: LANGUAGE_CODES,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] supportedLanguages is added to the response object but the CapabilitiesEnvelope interface in packages/cli/src/serve/types.ts was not updated. This causes a TypeScript build error (TS2353: Object literal may only specify known properties).

Suggested change
supportedLanguages: LANGUAGE_CODES,
policy: { permission: bridge.permissionPolicy },
supportedLanguages: LANGUAGE_CODES,

Add supportedLanguages?: string[] to the CapabilitiesEnvelope interface in types.ts (after the policy field, around line 234):

  /**
   * Language codes supported by this daemon. Additive — older
   * daemons omit this field. Clients should treat absence as
   * "unknown" rather than "none".
   */
  supportedLanguages?: string[];

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added supportedLanguages?: string[] to CapabilitiesEnvelope in types.ts.

// Update output language rule file immediately (no restart needed for LLM effect)
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
updateOutputLanguageFile(parsed);
updateOutputLanguageFile(parsed, config?.getOutputLanguageFilePath?.());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Unlike languageCommand.ts (lines 129-134) and acpAgent.ts (lines 2938-2942), this caller does not register the path via setOutputLanguageFilePath after the first-time write. When config.getOutputLanguageFilePath() returns undefined (no file existed at startup), the file is correctly written to the global default, but config.outputLanguageFilePath stays undefined for the session's lifetime.

This means subsequent reads via config.getOutputLanguageFilePath() (e.g., in side queries) continue to return undefined until the daemon restarts and Config is re-constructed.

Suggested change
updateOutputLanguageFile(parsed, config?.getOutputLanguageFilePath?.());
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
const targetPath = config?.getOutputLanguageFilePath?.();
updateOutputLanguageFile(parsed, targetPath);
if (!targetPath) {
config?.setOutputLanguageFilePath?.(getOutputLanguageFilePath());
}
}

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — extracted writeOutputLanguageAndRegisterPath() helper in languageUtils.ts, all 3 call sites (acpAgent, languageCommand, SettingsDialog) now include the registration step.

const results = await Promise.allSettled(
allSessions.map(async (s) => {
const cfg = s.getConfig();
const sessionPath = cfg.getOutputLanguageFilePath();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] In the multi-session refresh loop, if updateOutputLanguageFile throws for any session (e.g., EACCES on a project-level .qwen/ directory), the subsequent refreshHierarchicalMemory() and refreshSystemInstruction() calls are skipped for that session — leaving it with a stale system instruction.

Consider wrapping the file writes in a try/catch so the refresh calls always execute:

allSessions.map(async (s) => {
  const cfg = s.getConfig();
  const sessionPath = cfg.getOutputLanguageFilePath();
  try {
    if (sessionPath && sessionPath !== writtenPath) {
      updateOutputLanguageFile(settingValue, sessionPath);
    }
    if (!sessionPath) {
      const fallback = getOutputLanguageFilePath();
      updateOutputLanguageFile(settingValue, fallback);
      cfg.setOutputLanguageFilePath(fallback);
    }
  } catch (err) {
    debugLogger.warn('Failed to write output-language.md for session:', err);
  }
  await cfg.refreshHierarchicalMemory();
  await cfg.getGeminiClient()?.refreshSystemInstruction();
}),

Additionally, when multiple sessions share the same path (e.g., all undefined → global default), the same file is written N times with identical content. A Set<string> of already-written paths would avoid the redundant I/O.

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — wrapped file writes in try/catch so refreshHierarchicalMemory() and refreshSystemInstruction() always execute. Skipped Set<string> dedup (1-3 sessions, negligible I/O).

? OUTPUT_LANGUAGE_AUTO
: resolved;

const targetPath = session.getConfig().getOutputLanguageFilePath();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The three-step sequence here — get targetPath from config, call updateOutputLanguageFile(settingValue, targetPath), then register the global fallback via setOutputLanguageFilePath() when targetPath is undefined — is copy-pasted identically in languageCommand.ts:128-134, and partially (missing the registration step) in SettingsDialog.tsx:382.

This duplication has already produced a concrete bug within this PR: SettingsDialog.tsx omits the registration step, so when getOutputLanguageFilePath() returns undefined, the global path is never registered on the config. Any future call site that omits or misorders a step will silently break language switching.

Consider extracting a helper in languageUtils.ts that encapsulates the full sequence:

export function writeOutputLanguageAndRegisterPath(
  settingValue: string,
  config: {
    getOutputLanguageFilePath(): string | undefined;
    setOutputLanguageFilePath(p: string): void;
  },
): void {
  const targetPath = config.getOutputLanguageFilePath();
  updateOutputLanguageFile(settingValue, targetPath);
  if (!targetPath) {
    config.setOutputLanguageFilePath(getOutputLanguageFilePath());
  }
}

Then replace the three inline copies with a single call each.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — writeOutputLanguageAndRegisterPath() in languageUtils.ts replaces all 3 inline copies. This also fixed the SettingsDialog bug (comment #2) as a side effect.

- Add `supportedLanguages` to `CapabilitiesEnvelope` interface (TS2353)
- Extract `writeOutputLanguageAndRegisterPath()` helper in languageUtils
  to eliminate the duplicated get-path/write/register sequence across
  acpAgent, languageCommand, and SettingsDialog (fixes SettingsDialog
  missing the registration step)
- Wrap file writes in the multi-session refresh loop with try/catch so
  `refreshHierarchicalMemory` and `refreshSystemInstruction` always run
  even when a project-level write fails

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0

chiga0 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

已处理所有 review 评论,修复已推送。

评论处理结果:

# 评论 判断 处理
1 supportedLanguages 未添加到 CapabilitiesEnvelope 接口 ✅ 真问题 — 会导致 TS2353 已在 types.ts 中添加 supportedLanguages?: string[]
2 SettingsDialog.tsx 缺少 setOutputLanguageFilePath 注册 ✅ 真问题 — 首次创建时 Config 路径不一致 已通过提取 helper 统一修复
3 多 session 循环缺少 try/catch ✅ 有效建议 — 文件写入失败会跳过 refresh 已添加 try/catch 包裹,确保 refreshHierarchicalMemory 始终执行
4 提取 helper 消除三处重复 ✅ 有效建议 — 重复已导致评论 2 的 bug 已在 languageUtils.ts 中提取 writeOutputLanguageAndRegisterPath(),三处调用统一替换

跳过的部分:

  • 评论 3 中「用 Set<string> 去重避免重复 I/O」 — 属于优化而非 bug,且写入量极小(通常 1-3 session),不在本次修复范围

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 4 review findings addressed in commit f4e3ad9:

Comment 1 (server.ts:998 — supportedLanguages type): Fixed — added supportedLanguages?: string[] to CapabilitiesEnvelope in types.ts.

Comment 2 (SettingsDialog.tsx:383 — missing registration): Fixed — extracted writeOutputLanguageAndRegisterPath() helper, all 3 call sites now include the registration step.

Comment 3 (acpAgent.ts:2967 — multi-session error handling): Fixed — wrapped file writes in try/catch so refreshHierarchicalMemory() and refreshSystemInstruction() always execute. Skipped Set<string> dedup (1-3 sessions, negligible I/O).

Comment 4 (acpAgent.ts:2934 — extract helper): Done — writeOutputLanguageAndRegisterPath() in languageUtils.ts replaces all 3 inline copies.

@chiga0

chiga0 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review Overview (AI Generated)

PR: #4938 fix(daemon): language switch writes to wrong output-language.md path
Type: Bug Fix + Enhancement
Author: @chiga0 (self-review — posting as comment)
Change size: +125/-18 across 11 files, 2 commits
HEAD: f4e3ad97 (commit 2: "fix: address review findings")

Independent Review Result: 0 findings

Phase 1 blind review of all 11 file diffs found no Critical, Major, Minor, or Nit-level issues. The bug fix is well-targeted, the dedup helper is clean, error handling is thorough, and SDK additions are backward-compatible.

Recommendation: Approve (pending wenshao's re-review at HEAD).

Note: PR has merge conflicts with daemon_mode_b_main — needs rebase before merge.

Cross-Validation

wenshao posted 4 findings on commit 1 (dc14027a). All verified addressed in commit 2 (f4e3ad97):

# Severity Finding Fixed In Commit 2
1 Critical supportedLanguages missing from CapabilitiesEnvelope (TS2353) types.ts:235-240
2 Suggestion SettingsDialog missing setOutputLanguageFilePath registration ✅ Uses writeOutputLanguageAndRegisterPath
3 Suggestion Multi-session write failure blocks refreshHierarchicalMemory ✅ Per-session try/catch in refresh loop
4 Suggestion 3-step get-path→write→register sequence duplicated across 3 callers ✅ Extracted writeOutputLanguageAndRegisterPath helper

ci-bot comment on acpAgent.ts duplicates #4.

Additional Audit Coverage

Areas I independently checked beyond wenshao's findings:

  • Handler Parallelism: Compared writeOutputLanguageAndRegisterPath usage across all 3 callers (acpAgent, languageCommand, SettingsDialog) — all consistent, passing the correct config reference.
  • Data Provenance Tracing: Traced outputLanguageFilePath from Config → writeOutputLanguageAndRegisterPathupdateOutputLanguageFilewriteOutputLanguageFile → disk. Optimistic path (write to Config-bound path) and echo path (refreshHierarchicalMemory reads from Config-bound path) are now consistent. The ?? getOutputLanguageFilePath() fallback in acpAgent.ts correctly handles the case where registration hasn't happened yet.
  • Multi-Session Path Divergence: Verified the 3-branch logic in the multi-session refresh loop: (a) sessionPath && sessionPath !== writtenPath → direct write, (b) !sessionPathwriteOutputLanguageAndRegisterPath with registration, (c) same path → skip. Correctly prevents duplicate writes to the same file while handling uninitialized sessions.
  • State Field Initialization: outputLanguageFilePath setter on Config — verified all code paths either go through writeOutputLanguageAndRegisterPath (which registers on first write) or directly set via the new setter. No path where the field remains undefined after a successful write.
  • SDK Backward Compatibility: CapabilitiesEnvelope.supportedLanguages is optional with clear doc ("older daemons omit this field"). SetSessionLanguageResult has all required fields, matching the server's response shape.
  • Pre-existing Bug: Noted commit 1 fixes this.sessionOrThrow(sessionId) being called without capturing the return value — const session = this.sessionOrThrow(sessionId) was missing. Good catch.
  • Test Coverage: 48 languageCommand tests + 7 language route tests updated with the new mock methods (getOutputLanguageFilePath, setOutputLanguageFilePath).

This review was generated by QoderWork AI

- acpAgent.ts: consolidate duplicate languageUtils imports into one block
- SettingsDialog.tsx: keep writeOutputLanguageAndRegisterPath + take
  useVimModeState/useVimModeActions from base
- sdk.test.ts: fix unused import lint error from base

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No high-confidence issues found. The bug fix is correct and well-tested (845 tests pass). Two low-confidence suggestions for human review: (1) incomplete vi.mock for languageUtils.js in acpAgent.test.ts may cause confusing errors in future tests; (2) DaemonCapabilities SDK type could include supportedLanguages for typed discovery. — qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Thanks for this PR! The core fix — extracting writeOutputLanguageAndRegisterPath and adding multi-session fan-out — is a solid improvement. Deterministic checks (tsc, eslint, unit tests) all pass.

Below are findings from automated review across multiple dimensions (correctness, security, performance, test coverage, oncall readiness).


🔴 Critical: Missed call site — setCoreValue handler at line 6209

acpAgent.ts:6209updateOutputLanguageFile(normalizedValue) still uses the old API without session-specific path or session propagation. When an external client (e.g., desktop app via SDK extMethod) changes general.outputLanguage through qwen/settings/setCoreValue:

  1. It always writes to the global default path, ignoring any session-specific outputLanguageFilePath
  2. It never calls refreshHierarchicalMemory() or refreshSystemInstruction() on any session

This is the same bug class the PR fixes in the other three call sites. External clients setting the output language via the settings API will silently fail to update running sessions.

Suggested fix: After the updateOutputLanguageFile call, iterate this.sessions similarly to the language endpoint handler: write to each session's path and call refreshHierarchicalMemory() + refreshSystemInstruction().


🔵 Suggestion: Missing unit test for writeOutputLanguageAndRegisterPath

languageUtils.ts:183-197 — The new helper encapsulates the get-path → write → register-fallback sequence and is exercised from 3 call sites, but there is no languageUtils.test.ts. A direct unit test would catch regressions in the registration side-effect more precisely than integration-only coverage.


Inline comments below cover the remaining findings (refreshed flag semantics, redundant write, mock assertion gap).

const results = await Promise.allSettled(
allSessions.map(async (s) => {
const cfg = s.getConfig();
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The inner try/catch around file writes swallows errors before allSettled sees them, so failedCount only tracks refreshHierarchicalMemory() / refreshSystemInstruction() failures. The refreshed flag will be true even when every other session's output-language.md write failed silently (permission denied, disk full).

On session restart, those sessions will revert to the previous language with no user-visible error.

Suggestion: Let the file-write error propagate (remove the inner try/catch) so allSettled captures it as a rejection, or track write failures separately:

let fileWriteFailed = false;
try {
  const sessionPath = cfg.getOutputLanguageFilePath();
  if (sessionPath && sessionPath !== writtenPath) {
    updateOutputLanguageFile(settingValue, sessionPath);
  }
  if (!sessionPath) {
    cfg.setOutputLanguageFilePath(getOutputLanguageFilePath());
  }
} catch (err) {
  fileWriteFailed = true;
  debugLogger.warn('Failed to write output-language.md for session:', err);
}
await cfg.refreshHierarchicalMemory();
await cfg.getGeminiClient()?.refreshSystemInstruction();
// ...
refreshed = results.length === 0 || (failedCount === 0 && !fileWriteFailed);

Also, when !sessionPath, writeOutputLanguageAndRegisterPath(settingValue, cfg) writes to the global default path which was already written above (identical to writtenPath). Only the registration side-effect (cfg.setOutputLanguageFilePath(...)) is needed — the write is redundant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ec3b541:

  1. Removed inner try/catch — write errors now propagate to allSettled, making failedCount and refreshed accurate.
  2. For !sessionPath case: only register the path (cfg.setOutputLanguageFilePath(...)) — the global file was already written, so the write was indeed redundant.

config: {
getModel: vi.fn().mockReturnValue('test-model'),
getOutputLanguageFilePath: vi.fn().mockReturnValue(undefined),
setOutputLanguageFilePath: vi.fn(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The mock provides setOutputLanguageFilePath: vi.fn() (here and at lines 467, 510), but no test asserts that setOutputLanguageFilePath was actually called with the expected argument.

The path-registration side-effect (cfg.setOutputLanguageFilePath(getOutputLanguageFilePath())) is the key new behavior of writeOutputLanguageAndRegisterPath. Without an assertion like:

expect(config.setOutputLanguageFilePath).toHaveBeenCalledWith(
  expect.any(String),
);

…a future refactor could silently drop the registration call and tests would still pass. Consider adding this assertion to at least one test case that exercises the write-and-register path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added assertion in the "should save LLM output language setting" test that setOutputLanguageFilePath is called with a string when getOutputLanguageFilePath returns undefined.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Follow-up: Symlink Attack Vector

After the initial review, the attacker mindset audit identified a security issue worth flagging:

Severity: Critical (security)

File: packages/cli/src/utils/languageUtils.ts:160-167

Issue: writeOutputLanguageFile calls fs.writeFileSync(filePath, ...) without checking whether filePath is a symlink. The multi-session fan-out introduced in this PR amplifies the attack surface: previously only the current session's file was written; now ALL sessions' paths are written when any user changes language.

Attack scenario:

  1. A malicious repository plants .qwen/output-language.md as a symlink pointing to a sensitive file (e.g., ~/.bashrc, ~/.ssh/authorized_keys)
  2. The victim opens the project in daemon mode and runs /language zh (or has syncOutputLanguage: true)
  3. The fan-out writes to the symlink target, overwriting it with the language-rule markdown content
  4. The user's shell profile or SSH keys are replaced with Chinese-language instruction text

Suggested fix: Add a symlink check before writing:

export function writeOutputLanguageFile(language: string, targetPath?: string): void {
  const filePath = targetPath ?? getOutputLanguageFilePath();
  if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) {
    throw new Error(`Refusing to write to symlink: ${filePath}`);
  }
  const content = generateOutputLanguageFileContent(language);
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(filePath, content, 'utf-8');
}

This is a denial-of-service / file corruption attack rather than code execution, but overwriting ~/.bashrc could lock a user out or cause unexpected behavior on next login.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Follow-up: SetSessionLanguageResult missing from SDK barrel export

Severity: Medium

File: packages/sdk-typescript/src/index.ts (lines 130-171)

The new SetSessionLanguageResult type is exported from packages/sdk-typescript/src/daemon/index.ts (line 389) but is NOT re-exported from the main public barrel packages/sdk-typescript/src/index.ts. The barrel explicitly lists peer types like SetModelResult and SessionMetadataResult, but SetSessionLanguageResult is absent.

SDK consumers importing from @qwen-code/sdk cannot reference the return type of DaemonClient.setSessionLanguage(), even though the method itself is accessible via the DaemonClient class.

Fix: Add to the barrel in src/index.ts:

export type { SetSessionLanguageResult } from './daemon/index.js';

- Remove inner try/catch in multi-session loop so file-write failures
  are captured by Promise.allSettled and reflected in `refreshed`
- For sessions with no path: only register the global path (the file
  was already written by the primary write), skip the redundant write
- Add test assertion that setOutputLanguageFilePath is called on
  first-time creation

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
cfg.setOutputLanguageFilePath(getOutputLanguageFilePath());
}
await cfg.refreshHierarchicalMemory();
await cfg.getGeminiClient()?.refreshSystemInstruction();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] When the initiating session has a project-level outputLanguageFilePath (e.g. <cwd>/.qwen/output-language.md), writtenPath is that project path. But sessions without a path are registered to getOutputLanguageFilePath() (the global default ~/.qwen/output-language.md) — a file that was never written. Their refreshHierarchicalMemory() then reads stale content.

Fix: use writtenPath so the session points to the file that was actually written:

Suggested change
await cfg.getGeminiClient()?.refreshSystemInstruction();
cfg.setOutputLanguageFilePath(writtenPath);

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — restored writeOutputLanguageAndRegisterPath(settingValue, cfg) for !sessionPath sessions. When writtenPath is a project path, the global file was indeed never written, so we need the write here (not just registration).

const cfg = s.getConfig();
const sessionPath = cfg.getOutputLanguageFilePath();
if (sessionPath && sessionPath !== writtenPath) {
updateOutputLanguageFile(settingValue, sessionPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Commit ec3b541 removed the inner try/catch so write errors propagate to allSettled — but this means a write failure (EACCES, ENOSPC) throws out of the entire callback, skipping refreshHierarchicalMemory() and refreshSystemInstruction() for that session. The R1 reply said "wrapped file writes in try/catch so refresh always runs" but the actual code does the opposite.

Wrap only the write in try/catch so the refresh always executes:

const cfg = s.getConfig();
const sessionPath = cfg.getOutputLanguageFilePath();
try {
  if (sessionPath && sessionPath !== writtenPath) {
    updateOutputLanguageFile(settingValue, sessionPath);
  }
} catch (err) {
  debugLogger.warn(`Failed to write output-language.md for session:`, err);
}
if (!sessionPath) {
  cfg.setOutputLanguageFilePath(writtenPath);
}
await cfg.refreshHierarchicalMemory();
await cfg.getGeminiClient()?.refreshSystemInstruction();

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — restored try/catch around file writes only, so refreshHierarchicalMemory() and refreshSystemInstruction() always execute regardless of write outcome.

);
// Verify path registration on first-time creation (getOutputLanguageFilePath returned undefined)
expect(
(mockContext.services.config as Record<string, unknown>)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This cast triggers two tsc errors:

  • TS2352: Conversion of type 'Config | null' to type 'Record<string, unknown>' may be a mistake
  • TS4111: Property 'setOutputLanguageFilePath' comes from an index signature, so it must be accessed with ['setOutputLanguageFilePath']

Fix: use bracket notation with a double cast, or type the mock more precisely:

Suggested change
(mockContext.services.config as Record<string, unknown>)
expect(
(mockContext.services.config as unknown as Record<string, unknown>)[
'setOutputLanguageFilePath'
],
).toHaveBeenCalledWith(expect.any(String));

— qwen3.7-plus via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — switched to double cast (as unknown as Record<string, unknown>) with bracket notation.

- Restore try/catch around file writes in multi-session loop so refresh
  always runs (write failures are logged, not propagated)
- Restore writeOutputLanguageAndRegisterPath for !sessionPath sessions
  to handle the case where writtenPath is a project-level path and the
  global file was never written
- Fix TS cast in test assertion (double-cast + bracket notation)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
const writtenPath =
session.getConfig().getOutputLanguageFilePath() ??
getOutputLanguageFilePath();
const allSessions = [...this.sessions.values()];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The multi-session fan-out loop (lines 5542–5571) is the most complex new logic in this PR — three branching conditions handle different outputLanguageFilePath scenarios across sessions — but has no test coverage. A future refactor of the path comparison, write/refresh ordering, or writtenPath computation could silently break multi-session language propagation without any test catching it.

Consider adding a test that creates multiple mock sessions with varying getOutputLanguageFilePath() return values (project path, undefined, same as originating), triggers the language ext-method with syncOutputLanguage: true, and asserts:

  1. Files are written to correct paths
  2. setOutputLanguageFilePath is called on sessions with no path
  3. refreshHierarchicalMemory is called on every session

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this loop deserves dedicated test coverage. Filing as a follow-up — the multi-session fan-out requires mocking QwenAgent + multiple Session instances with varying Config, which is a meaningful test infrastructure addition beyond this bug fix scope.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 250b50b — test covers 3 session scenarios (project path / different project path / no path) and asserts file writes, path registration, and refresh calls for each.

@wenshao

wenshao commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Local runtime verification report (A/B: base daemon_mode_b_main@89921cc7f vs trial-merge of this PR)

Verified with real esbuild bundles on both sides (not vitest-only): tmux-driven qwen serve daemons + interactive TUI, a mock OpenAI-compatible provider capturing the actual request payloads sent to the model, a direct --acp stdio driver for the multi-cwd-session case, and the built sdk-typescript DaemonClient.

Verdict: the fix works end-to-end and I recommend merging it — after one small test-hygiene fix (see Finding 1, one-line change, fix verified locally).


Core bug & fix — confirmed at the model-payload level

S1 — project-level output-language.md exists (workspace .qwen/output-language.md = English sentinel; REST POST /session/:id/language {"language":"zh","syncOutputLanguage":true}):

base (bug) merged (fix)
HTTP response {"language":"zh","outputLanguage":"Chinese","refreshed":true} (claims success) same
project file after switch unchanged (English) rewritten to Chinese
global ~/.qwen/output-language.md leaked write (Chinese) untouched ✅
next captured model request still English directive — switch silently ineffective Chinese directive present

The base branch reports success on all surfaces (REST 200, TUI shows "LLM output language set to Chinese") while the model keeps receiving the old language — exactly the silent failure described. The fix makes write path = read path (config.getOutputLanguageFilePath()).

S4 — one agent process, two sessions in different cwds (Zed-style; drove cli.js --acp over stdio, both workspaces have project files; switch on session 1):

  • base: neither project file updated (write leaked to global); session 2's next request still carried the old English sentinel.
  • merged: both sessions' project files updated (sessionPath !== writtenPath branch works), and session 2's next captured request actually carried the Chinese directive. The cross-session refresh is now effective instead of re-reading stale files.
  • Note (by design, worth being aware of): the per-session endpoint now writes other sessions' project files too — intentional propagation, consistent with the pre-existing refresh-all-sessions behavior; in daemon mode children are pooled per-cwd so this only materializes for direct ACP/Zed multi-cwd connections.

S5 — interactive TUI /language output zh (tmux, project file present): same split — base writes global and the TUI still prints the success message; merged writes the project file. Fix covers languageCommand.ts correctly.

S3 — GET /capabilities: base has no supportedLanguages; merged returns ["en","zh-TW","zh","ru","de","ja","pt","fr","ca","auto"], matching what POST /session/:id/language validates against.

SDK: built DaemonClient from the merged tree against a live daemon — capabilities().supportedLanguages ✅, setSessionLanguage(sid,'fr',{syncOutputLanguage:true}){"language":"fr","outputLanguage":"French","refreshed":true} and the workspace file flipped to French in real time ✅, invalid language → clean 400 with the allowed list ✅. (Heads-up: the method is capabilities(), not getCapabilities() — the PR body's test plan is fine, just noting for SDK users.)

Unit tests A/B — identical on both sides: languageCommand 48✅, languageUtils 45✅, SettingsDialog 53✅, server.test -t language 7✅, core config.test 202✅, acpAgent 108✅+2❌ — the 2 failures (status ext methods … workspace snapshots) fail identically on base, pre-existing and unrelated.


Finding 1 (should fix before merge): SettingsDialog.test.tsx now overwrites the developer's real ~/.qwen/output-language.md

Bisected and A/B-confirmed: running vitest run src/ui/components/SettingsDialog.test.tsx on the merged tree rewrites the real user-level ~/.qwen/output-language.md (my machine's file went 中文 → English; mtime matched the test run). The same suite on base does not touch it; languageUtils.test.ts / languageCommand.test.ts are clean on both sides.

Root cause: the suite's mock spreads the actual module and only stubs the old entry point:

vi.mock('../../utils/languageUtils.js', async () => {
  const actual = await vi.importActual('../../utils/languageUtils.js');
  return { ...actual, updateOutputLanguageFile: vi.fn() };
});

The dialog now calls writeOutputLanguageAndRegisterPath, which resolves to the real implementation via ...actual; its internal call to updateOutputLanguageFile is an intra-module binding that bypasses the mock → real fs.writeFileSync to the global path on every test run (dev machines and CI runners alike). Tests still pass, so it's invisible.

One-line fix, verified locally (53/53 still pass, no more file write):

  return { ...actual, updateOutputLanguageFile: vi.fn(), writeOutputLanguageAndRegisterPath: vi.fn() };

Finding 2 (accuracy note on the PR description): the "first-time creation" branch is effectively unreachable in real flows

gemini.tsx calls initializeLlmOutputLanguage() in main() before loadCliConfig for every non-bare invocation — including serve and each spawned --acp child. Verified empirically: starting a daemon with a completely fresh $HOME auto-creates the global file immediately, so by the time any session Config is constructed, a file exists and outputLanguageFilePath is bound (S2 fresh-env A/B: both base and merged behave identically and correctly there — base passes that scenario not because of registration, but because the bound path is the global one). The setOutputLanguageFilePath() registration branch is defensively correct and unit-tested, but in production it only triggers in corner cases (file deleted while the daemon runs + a later session, startup write failure, --bare). No action needed — just don't rely on the PR-description framing of fix #2 when reviewing.

Finding 3 (base-branch health, not caused by this PR)

  • daemon_mode_b_main currently fails npm run build: packages/core src/telemetry/sdk.test.ts(68,1) TS6133 (unused createSessionRootContext). This PR incidentally fixes it (the _createSessionRootContext rename that looks like formatting noise is actually what un-breaks the branch build).
  • Second pre-existing break on both sides: packages/acp-bridge src/status.ts(782,14) TS2739 — the serve hook-event meta table is missing UserPromptExpansion and InstructionsLoaded (fallout from a main merge). tsc fails for acp-bridge and cascades into packages/cli builds, though emitted JS still lets the esbuild bundle work. Needs a separate fix on the branch.

Minor notes

  • ~10 files in the diff (trace-context*, useGeminiStream, i18n locales, extensions/consent|utils, forkCommand, core config.test, qc-helper/SKILL.md) are prettier-only reformatting unrelated to the fix — all verified prettier-clean per repo config, so they won't flip back; just review noise.
  • The two-dot GitHub diff makes it look like DaemonClient.reloadEnv() (feat(daemon): add POST /workspace/reload-env for hot-reloading env vars and session auth #4924) gets deleted — it does not: verified the 3-way merge result keeps it (the PR branched before feat(daemon): add POST /workspace/reload-env for hot-reloading env vars and session auth #4924; that hunk is an artifact of the stale fork point). Merged sdk-typescript builds clean.
  • Code-reading observation (not runtime-verified): in --bare mode, /language output via writeOutputLanguageAndRegisterPath would register the global path on a Config that deliberately skipped language-file context at load; probably irrelevant in practice, mentioning for completeness.
Methodology / environment
  • Worktrees: base = origin/daemon_mode_b_main @ 89921cc7f; merged = clean git merge of pr-4938 head (07d99b8bc) into base (26 files, no conflicts; matches git merge-tree).
  • Both sides: full npm run build + npm run bundle (base needed the test-only TS6133 one-liner pre-applied to compile at all — see Finding 3; zero runtime effect, esbuild bundles from sources).
  • Bundle distinctness proven: merged dist/cli.js contains 5 hits for setOutputLanguageFilePath|writeOutputLanguageAndRegisterPath and the serve chunk contains supportedLanguages; base has 0.
  • Daemons under tmux with isolated $HOME per scenario; auth selectedType=openai + OPENAI_BASE_URL pointed at a local mock provider that dumps every /chat/completions request body to disk — assertions are made on what the model would actually receive, not on logs.
  • S2 fresh-env, S1 project-file, S4 dual-cwd ACP stdio, S5 tmux TUI, S3/SDK live REST.

秦奇 and others added 2 commits June 11, 2026 09:50
Verify the fan-out loop handles three session scenarios correctly:
- Session A (project path): writeOutputLanguageAndRegisterPath called
- Session B (different project path): updateOutputLanguageFile called
- Session C (no path): writeOutputLanguageAndRegisterPath + path
  registration
- All sessions: refreshHierarchicalMemory + refreshSystemInstruction

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- acpAgent.ts: consolidate languageUtils imports (base moved them)
- sdk.test.ts: drop unused createSessionRootContext import (base removed it)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] packages/sdk-typescript/src/index.tsSetSessionLanguageResult is exported from daemon/index.ts (line 389) but not re-exported from the top-level SDK barrel (src/index.ts). Peer types like SetModelResult (line 168) are properly re-exported. SDK consumers importing from @qwen-code/sdk cannot access this type, making setSessionLanguage() return type unusable.

Fix: Add type SetSessionLanguageResult to the export list in packages/sdk-typescript/src/index.ts.


Test coverage gaps (Suggestion):

  • writeOutputLanguageAndRegisterPath (languageUtils.ts:183) — the central new helper replacing 3 inline copies — has no unit tests. All 3 path scenarios (project path, global path, first-time creation) are untested.
  • The new targetPath parameter on writeOutputLanguageFile and updateOutputLanguageFile is untested — only the global-default branch is exercised.
  • supportedLanguages in /capabilities response (server.ts:998) — no test asserts its presence or value.
  • SDK setSessionLanguage() method (DaemonClient.ts:1618) — no tests for URL, request body, or error handling.
  • setOutputLanguageFilePath() setter (config.ts:3030) — no test in config.test.ts.
  • SettingsDialog.test.tsx mock is stale — still mocks updateOutputLanguageFile, not writeOutputLanguageAndRegisterPath.
  • Fan-out test covers only the happy path — same-path skip, write error, and fileWriteOk = false branches are untested.

Observability gap (Suggestion):

  • Error logs at acpAgent.ts:5526 and 5559 omit session ID and target file path. When multiple sessions fail, logs are indistinguishable.

— qwen3.7-max via Qwen Code /review

}
} catch (err) {
debugLogger.warn(
'Failed to write output-language.md for session:',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The error log here ('Failed to write output-language.md for session:') omits the session ID and target file path. When multiple sessions fail writes concurrently, the logs are indistinguishable — an operator cannot tell which session or which .qwen/ directory failed.

The same applies to the initiator's log at line 5526.

Suggested change
'Failed to write output-language.md for session:',
debugLogger.warn(
`Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`,
err,
);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — 已在日志中加入 session ID 和目标路径,方便排查多 session 并发写入失败的场景。

PromptResult,
PromptTextContent,
SetModelResult,
SetSessionLanguageResult,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] SetSessionLanguageResult is added to the daemon barrel export here, but is not re-exported from the top-level SDK barrel at packages/sdk-typescript/src/index.ts. Peer types like SetModelResult (line 168 of src/index.ts) are properly re-exported. This means SDK consumers importing from @qwen-code/sdk cannot use the return type of setSessionLanguage().

Fix: Add type SetSessionLanguageResult to the export list in packages/sdk-typescript/src/index.ts, alongside the existing SetModelResult and SessionMetadataResult exports.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

确实漏了,已在 packages/sdk-typescript/src/index.ts 中补上 type SetSessionLanguageResult 的 re-export,与 SetModelResult 同级。

}

this.sessionOrThrow(sessionId);
const session = this.sessionOrThrow(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Lines 5475-5478 compute [...SUPPORTED_LANGUAGES.map((l) => l.code), 'auto'] inline, duplicating the identical expression hoisted as LANGUAGE_CODES in server.ts:971. If a new language code is added to one site but not the other, the ACP and HTTP endpoints will silently disagree on what is valid.

Extract a single shared constant (e.g., export LANGUAGE_CODES from i18n/index.ts or languageUtils.ts) and import it in both server.ts and acpAgent.ts.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

认同重复计算的问题。不过提取共享常量属于跨模块重构(server.ts 在 serve 层,acpAgent 在 ACP 层),放到 follow-up 更合适,避免本 PR 范围扩大。

* that was previously duplicated across acpAgent, languageCommand, and
* SettingsDialog.
*/
export function writeOutputLanguageAndRegisterPath(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] writeOutputLanguageAndRegisterPath — the core fix function — has no direct unit tests in languageUtils.test.ts. The only coverage comes from acpAgent.test.ts where it is fully mocked, so the real implementation is never exercised.

Key untested behaviors:

  • Writes to config.getOutputLanguageFilePath() when it returns a path
  • Falls back to the global path when config path is undefined
  • Calls setOutputLanguageFilePath with the global path only on first creation
  • Handles null/undefined config gracefully

Add a describe('writeOutputLanguageAndRegisterPath') block covering these cases, plus a test for writeOutputLanguageFile(language, targetPath) verifying the custom path is used.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 languageUtils.test.ts 中新增 4 个直接测试:config-bound 路径写入、undefined 回退 + 注册、null config、undefined config。覆盖了核心修复函数的关键分支。

- Include session ID and target path in multi-session write error logs
- Re-export SetSessionLanguageResult from top-level SDK barrel
- Add 4 unit tests for writeOutputLanguageAndRegisterPath covering
  config-bound path, undefined fallback, null/undefined config

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
}
} catch (err) {
debugLogger.warn(
`Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] sessionPath is declared with const inside the try block (line 5554) but referenced here in the catch block. These are separate block scopes — sessionPath is not visible in catch.

Impact:

  1. TypeScript compilation errorTS2304: Cannot find name 'sessionPath' (confirmed with tsc --noEmit --strict).
  2. Runtime ReferenceError: sessionPath is not defined — if cfg.getOutputLanguageFilePath() throws, the catch block will throw a second error that masks the original, making debugging harder.

Suggested fix: Declare sessionPath before the try block so it is accessible in both try and catch:

let sessionPath: string | undefined;
try {
  sessionPath = cfg.getOutputLanguageFilePath();
  if (sessionPath && sessionPath !== writtenPath) {
    updateOutputLanguageFile(settingValue, sessionPath);
  }
  if (!sessionPath) {
    writeOutputLanguageAndRegisterPath(settingValue, cfg);
  }
} catch (err) {
  debugLogger.warn(
    `Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`,
    err,
  );
}

— automated review (qwen-code)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

好眼力,确实是块作用域 bug。已将 const sessionPath 提到 try 外改为 let sessionPath: string | undefined,catch 中可以正常访问了。

sessionPath was declared with const inside try but referenced in catch,
causing a block-scope ReferenceError. Move to let before try.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
}
} catch (err) {
debugLogger.warn(
`Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] sessionPath is declared with const inside the try block (line 5554), but is referenced here in the catch block. Since const is block-scoped, this is a TypeScript compile error (TS2304: Cannot find name 'sessionPath'). At runtime, the catch block would throw a ReferenceError, masking the original file-write error and skipping refreshHierarchicalMemory() / refreshSystemInstruction() for that session.

Suggested change
`Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`,
const cfg = s.getConfig();
let sessionPath: string | undefined;
try {
sessionPath = cfg.getOutputLanguageFilePath();
if (sessionPath && sessionPath !== writtenPath) {
updateOutputLanguageFile(settingValue, sessionPath);
}
if (!sessionPath) {
writeOutputLanguageAndRegisterPath(settingValue, cfg);
}
} catch (err) {
debugLogger.warn(
`Failed to write output-language.md for session ${s.getId()} (path=${sessionPath ?? 'global-default'}):`,
err,
);
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在上一个 commit (47217b9) 中修复了这个问题,const 已改为 let 并提升到 try 外。


mockConnectionState.resolve();
await agentPromise;
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The multi-session fan-out test only covers the happy path where all writes succeed. The catch branch in the fan-out loop is never exercised — which is exactly why the sessionPath scoping bug (see comment on acpAgent.ts:5563) was not caught by tests.

Consider adding a test where one session's getOutputLanguageFilePath() throws, verifying that:

  • The error is caught and logged without crashing the callback
  • Other sessions' writes and refreshes still execute
  • refreshHierarchicalMemory() / refreshSystemInstruction() still run for the failed session

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已添加 catch 分支测试 — 模拟一个 session 的 updateOutputLanguageFile 抛 EACCES,验证该 session 的 refreshHierarchicalMemoryrefreshSystemInstruction 仍然被调用。

);
});
});
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] writeOutputLanguageFile and updateOutputLanguageFile both gained a new optional targetPath parameter, but no test passes a targetPath argument. The fan-out logic in acpAgent.ts depends on updateOutputLanguageFile(settingValue, sessionPath) writing to the correct path — this dependency is untested.

Suggested tests:

it('writes to targetPath when provided', () => {
  writeOutputLanguageFile('Chinese', '/custom/.qwen/output-language.md');
  expect(fs.writeFileSync).toHaveBeenCalledWith(
    '/custom/.qwen/output-language.md',
    expect.stringContaining('Chinese'),
    'utf-8',
  );
});

it('forwards targetPath to writeOutputLanguageFile', () => {
  updateOutputLanguageFile('French', '/proj/.qwen/output-language.md');
  expect(fs.writeFileSync).toHaveBeenCalledWith(
    '/proj/.qwen/output-language.md',
    expect.stringContaining('French'),
    'utf-8',
  );
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 writeOutputLanguageFile 的 describe 块中添加了 targetPath 参数测试,验证自定义路径写入和目录创建。

@@ -1651,6 +1652,33 @@ export class DaemonClient {
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This new SDK method has no tests. It includes URL encoding, body serialization, syncOutputLanguage default (false), and error handling via failOnError — all unverified. Consider adding a test that asserts the correct HTTP request is made and the response is properly deserialized.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DaemonClient.setSessionLanguage() 的 SDK 测试涉及 HTTP client mock 基础设施,和本 PR 的 bug fix 范围有距离。作为 follow-up 更合适,避免 scope 继续扩大。

- acpAgent: test that refreshHierarchicalMemory still runs when a
  session's file write throws (catch branch coverage)
- languageUtils: test writeOutputLanguageFile with custom targetPath

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0 chiga0 requested review from DragonnZhang and ytahdn June 11, 2026 10:44
@chiga0 chiga0 merged commit 38f5121 into daemon_mode_b_main Jun 11, 2026
44 of 45 checks passed
@chiga0 chiga0 deleted the fix/language-switch-path-mismatch branch June 11, 2026 11:19

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No high-confidence critical issues found. The core bug fix is correct and well-tested (100 tests pass). The Suggestion-level findings (setCoreValue handler consistency, refreshed flag semantics, supportedLanguages test, SettingsDialog try/catch) can be addressed as follow-ups. — qwen3.7-max via Qwen Code /review

@@ -997,6 +999,7 @@ export function createServeApp(
workspaceCwd: boundWorkspace,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] supportedLanguages in /capabilities response has no test assertion.

None of the existing GET /capabilities tests verify the presence or contents of the new supportedLanguages field. If accidentally removed or returning wrong values, no test catches it.

Suggested change
workspaceCwd: boundWorkspace,
// In server.test.ts ~line 1490, add:
expect(res.body.supportedLanguages).toEqual(expect.arrayContaining(['zh', 'auto']));

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Update output language rule file immediately (no restart needed for LLM effect)
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
updateOutputLanguageFile(parsed);
writeOutputLanguageAndRegisterPath(parsed, config);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] SettingsDialog test mock not updated for writeOutputLanguageAndRegisterPath.

The production code now calls writeOutputLanguageAndRegisterPath(parsed, config), but the test mock at line 173 still only provides updateOutputLanguageFile: vi.fn(). No assertion verifies that config.setOutputLanguageFilePath is called on first-time path registration via the Settings dialog.

Suggested change
writeOutputLanguageAndRegisterPath(parsed, config);
// Add to the mock:
writeOutputLanguageAndRegisterPath: vi.fn(),
// Add assertion in the Output Language test:
expect(writeOutputLanguageAndRegisterPath).toHaveBeenCalledWith(expect.any(String), config);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

* Returns the path to the LLM output language rule file (~/.qwen/output-language.md).
*/
function getOutputLanguageFilePath(): string {
export function getOutputLanguageFilePath(): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Name collision: module-level getOutputLanguageFilePath() shares a name with the Config instance method config.getOutputLanguageFilePath().

Both appear on consecutive lines in acpAgent.ts:5546-5547:

session.getConfig().getOutputLanguageFilePath() ??  // instance — session-bound
getOutputLanguageFilePath();                        // module — always global

A future maintainer reading this without cross-referencing imports won't realize these are different functions returning potentially different values.

Suggested change
export function getOutputLanguageFilePath(): string {
// Rename the module export to getGlobalOutputLanguageFilePath()

— DeepSeek/deepseek-v4-pro via Qwen Code /review

);
}
const writtenPath =
session.getConfig().getOutputLanguageFilePath() ??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] writtenPath computed with a ?? getOutputLanguageFilePath() fallback that lacks documentation.

The fallback handles the edge case where writeOutputLanguageAndRegisterPath succeeded but the config path was still undefined (registration falls through to global default). The semantics require 3 hops of reasoning to understand.

Suggested change
session.getConfig().getOutputLanguageFilePath() ??
// Path written by writeOutputLanguageAndRegisterPath above;
// falls back to global when the initiator session had no
// pre-existing config-bound path.
const writtenPath =
session.getConfig().getOutputLanguageFilePath() ??
getOutputLanguageFilePath();

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] SDK DaemonCapabilities type missing supportedLanguages field

The server-side CapabilitiesEnvelope adds supportedLanguages?: string[] and GET /capabilities emits it, but the SDK's DaemonCapabilities interface (types.ts:24) was not updated. TypeScript consumers of DaemonClient.capabilities() cannot access supportedLanguages through the typed API.

// In packages/sdk-typescript/src/daemon/types.ts, add to DaemonCapabilities:
supportedLanguages?: string[];

— qwen3.7-max via Qwen Code /review

workspaceCwd: boundWorkspace,
// Active mediation policy under the `policy` namespace.
policy: { permission: bridge.permissionPolicy },
supportedLanguages: LANGUAGE_CODES,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] supportedLanguages in /capabilities response has no test assertion.

None of the existing GET /capabilities tests verify the presence or contents of the new supportedLanguages field. If accidentally removed or returning wrong values, no test catches it.

Suggested change
supportedLanguages: LANGUAGE_CODES,
// In server.test.ts ~line 1490, add:
expect(res.body.supportedLanguages).toEqual(expect.arrayContaining(['zh', 'auto']));

— qwen3.7-max via Qwen Code /review

// Update output language rule file immediately (no restart needed for LLM effect)
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
updateOutputLanguageFile(parsed);
writeOutputLanguageAndRegisterPath(parsed, config);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] SettingsDialog test mock not updated for writeOutputLanguageAndRegisterPath.

The production code now calls writeOutputLanguageAndRegisterPath(parsed, config), but the test mock (line 173 of SettingsDialog.test.tsx) still only provides updateOutputLanguageFile: vi.fn(). No assertion verifies that config.setOutputLanguageFilePath is called on first-time path registration via the Settings dialog.

Suggested change
writeOutputLanguageAndRegisterPath(parsed, config);
// Add to the mock in SettingsDialog.test.tsx:
writeOutputLanguageAndRegisterPath: vi.fn(),
// Add assertion in the Output Language test:
expect(writeOutputLanguageAndRegisterPath).toHaveBeenCalledWith(expect.any(String), config);

— qwen3.7-max via Qwen Code /review

* Returns the path to the LLM output language rule file (~/.qwen/output-language.md).
*/
function getOutputLanguageFilePath(): string {
export function getOutputLanguageFilePath(): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Name collision: module-level getOutputLanguageFilePath() shares a name with the Config instance method config.getOutputLanguageFilePath().

Both appear on consecutive lines in acpAgent.ts:5547-5548:

session.getConfig().getOutputLanguageFilePath() ??  // instance — session-bound
getOutputLanguageFilePath();                        // module — always global

A future maintainer reading this without cross-referencing imports won't realize these are different functions returning potentially different values (string | undefined vs string).

Suggested change
export function getOutputLanguageFilePath(): string {
// Rename the module export:
export function getGlobalOutputLanguageFilePath(): string {

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants