docs/design/2026-08-29-user-level-language-sync.md
POST /language)Date: 2026-08-29 Upstream issue: https://github.com/QwenLM/qwen-code/issues/10234
Daemon-backed hosts (DAC welcome page, Web Shell before session open) need to
synchronize the user-level language preference before any session exists.
The daemon currently exposes language synchronization only through
POST /session/:id/language, which is wrapped in withOwnerMutableSession and
therefore requires an existing owned, mutable session. A fabricated session ID
is rejected.
The session requirement is a poor fit because most of the operation is already
user-level or process-global (see sessionLanguage in
packages/cli/src/acp-integration/acpAgent.ts):
setLanguageAsync() switches the process UI language;general.language / general.outputLanguage persist at SettingScope.User;~/.qwen/output-language.md is written when no project-bound file
is involved;Two adjacent facts close the remaining escape hatches:
initializeLlmOutputLanguage() intentionally preserves an existing valid
output-language.md, so creating a new session does not repair a stale
output language;POST /workspace/settings excludes general.outputLanguage as TUI-only
(TUI_ONLY_SETTINGS in workspace-settings.ts).syncOutputLanguage is true.POST /session/:id/language (kept for backward compatibility
and for project-bound output-language semantics).initializeLlmOutputLanguage() preservation behavior.POST /workspace/settings keeps excluding general.outputLanguage:
persistence is not synchronization (no live i18n switch, no session refresh).Per the daemon-route ownership taxonomy, the new route is process-global:
~/.qwen/settings.json,
~/.qwen/output-language.md) and the daemon process i18n./workspaces/:workspace/language) was
rejected: it would disguise a user-global side effect as a workspace
resource, and in multi-workspace mode it would silently skip other
workspaces' runtimes.POST /language
Authorization: Bearer <token>
x-qwen-client-id: <clientId> # optional, event attribution only
Content-Type: application/json
{ "language": "zh", "syncOutputLanguage": true }
language: validated against the same LANGUAGE_CODES whitelist as the
session route (400 invalid_language on mismatch).syncOutputLanguage: optional boolean (400 invalid_sync_flag).mutate() gate — same bar as the sibling session route.
Language switching is low-severity; a strict gate would lock out the local
no-token developer mode that welcome-page hosts rely on. The clientId header
is validated against the union of all registered runtimes' known clients
(parseAndValidateWorkspaceClientId) and used only for event attribution.Response (mirrors SetSessionLanguageResult, with a refresh summary):
{
"language": "zh",
"outputLanguage": "Chinese",
"refresh": { "runtimes": 2, "sessions": 5, "failed": 0 }
}
Zero sessions / zero runtimes is a 200 with a zeroed refresh summary —
this is the core acceptance criterion.
Capability: /capabilities returns a features string array; hosts detect the
route with features.includes('user_language_sync').
general.language at user scope;resolveOutputLanguageOrPreserveAuto() and write the global
output-language.md, then persist general.outputLanguage at user
scope (when syncOutputLanguage);500 persist_error; the failing step and
everything after it is skipped (earlier applied steps are not rolled
back);setLanguageAsync() for the daemon process itself (best-effort);language_changed workspace event with originatorClientId.Promise.allSettled) to every trusted runtime with a live ACP
channel: new ext-method qwen/control/user/language (sessionless). The
runtime switches its own process i18n, reloads user-scope settings from
disk (reloadScopeFromDisk), and, when syncOutputLanguage is true,
refreshes each of its sessions (refreshHierarchicalMemory +
refreshSystemInstruction).settings.json. The
daemon persists once; runtimes only call reloadScopeFromDisk(User) to pick
up the new values — no writes from any child process..qwen/output-language.md continues to win — project
override semantics fall out naturally. This differs intentionally from the
session route, which writes the new value into every session's project file;
the protocol doc must state this difference.mutate() stays non-strict for parity with POST /session/:id/language
and to keep loopback no-token developer mode working.| Scenario | Behavior |
|---|---|
| Zero sessions, zero workspaces | 200, refresh summary all zeros |
| Persistence failure | 500 persist_error; later steps skipped, earlier steps not rolled back |
| Partial fan-out failure | 200, refresh.failed > 0, warn-logged |
| Runtime without live channel | Skipped, not counted as failed |
| Open loopback without token | Allowed (non-strict gate, sibling parity) |
The change is purely additive (new route + new ext-method + new SDK method +
new capability flag). Older daemons 404 the route; hosts detect
user_language_sync via /capabilities and degrade to the previous behavior
(persist UI language via POST /workspace/settings, which already accepts
general.language at user scope, and accept that output language only applies
to future sessions).
| Layer | Location | Change |
|---|---|---|
| Protocol | packages/acp-bridge/src/status.ts | SERVE_CONTROL_EXT_METHODS.userLanguage |
| Bridge | packages/acp-bridge/src/bridge.ts, bridgeTypes.ts | setUserLanguage() over the runtime control channel |
| Runtime | packages/cli/src/acp-integration/acpAgent.ts | Sessionless userLanguage case |
| Route | packages/cli/src/serve/routes/user-language.ts (new) | POST /language; telemetry catalog entry |
| Capability | packages/cli/src/serve/capabilities.ts | registry entry + conditional predicate |
| SDK | packages/sdk-typescript/src/daemon/ | setUserLanguage() + SetUserLanguageResult |
| Docs | docs/developers/qwen-serve-protocol.md | Contract, ownership class, semantic difference vs session route |
| Tests | server/acpAgent/bridge/DaemonClient test files | Zero-session 200, 400s, persist-failure purity, partial fan-out, project override untouched |