fix(web-shell): align welcome models and make UI language sessionless (#16297)

* fix(web-shell): align welcome models and make UI language sessionless

* docs(serve): index workspace UI language endpoint

* fix(web-shell): address sessionless language review feedback

* fix(serve): preserve workspace language during deferred sync

---------

Co-authored-by: luoyun <[email protected]>
This commit is contained in:
liuxuely0626andluoyun authored and GitHub committed 2026-11-05 16:23:23 +08:00
1 parent de940c41cb
commit 2f4608c00b
27 files changed
+1501 -184

No files matched your search

@@ -0,0 +1,73 @@
# Sessionless welcome settings
[English](sessionless-welcome-settings.md) | [简体中文](sessionless-welcome-settings.zh-CN.md)
## Problem
Web Shell welcomes users before creating a conversation. Its model preview can
publish a saved model absent from the catalog, while authentication later falls
back to a configured primary model. UI language changes currently send a slash
prompt, creating a conversation and reapplying the stale model. Even users with
no configured connection must be able to change UI language.
## Decision
Keep the existing Core authentication fallback and persistence policies. For a
missing catalog model, resolve the welcome preview through the same Core sync
without authentication, network requests, or settings writes. Preserve explicit
manual connections and existing endpoint selection for registered models.
Send UI language changes through sessionless APIs. Reuse the process-global
user language route. Add a trusted, selected-workspace language route that
persists only that workspace and synchronizes only its already-live runtime
channels. A cold runtime is not started; it reads the persisted setting later.
Both paths preserve output language and model selection. Keep generation and
client ownership guards. No unresolved workspace may fall back to the primary.
For SSH workspaces, settings and agent runtime remain local to the registered
workspace anchor, as with the existing workspace settings route. Permit the
language route through the SSH boundary; do not write remote project files.
The Web Shell settings picker and local `/language ui` command share this path.
Preserve user/workspace scope, host language authority and error rollback.
Refresh session command metadata only when a session already exists.
In a Live session, workspace UI language targets the settings provider's bound
workspace, just like the other settings rows. An unresolved target is refused;
the composer workspace resolver still returns no project target for Live and
standalone sessions.
Scope flags may precede or follow the UI locale, matching the CLI; combining
`--project` and `--global` remains invalid. Adopt the response's effective locale
after a successful write, while preserving an explicit host language override.
Report partial live-refresh failures as a warning without rolling back the
persisted choice. Workspace validation reports language and unsupported sync
flags separately, with an explanatory error and allowed locales where relevant.
If settings cannot be reloaded after a committed write, return the saved request
with a failed refresh summary and log the cause, without syncing an unverified
locale or publishing it. Generation closure still returns 503. A cold paired
Bridge retains its workspace language across later user writes while preserving
the latest output-language refresh request for a late Managed channel.
## Acceptance
- With a missing saved model and a preview-only catalog, welcome and the first
conversation select the same model without rewriting `model.name`.
- A registered selection and its endpoint remain unchanged; manually configured
runtime connections are not replaced with an unrelated catalog entry.
- UI language changes succeed with no models or sessions and do not create a
conversation, select a model, invoke a model API, or alter output language.
- User writes affect user settings; workspace writes affect only the resolved,
trusted workspace, including secondary workspaces. Invalid or removed
workspaces fail without mutating primary or user settings.
- In a Live session, the Settings workspace language picker and project-scoped
language command use the same bound workspace without creating a session or
sending a prompt. Standalone sessions still refuse project-scoped commands.
- UI language commands accept either scope-flag position; a masked choice uses
the effective returned locale. Partial refresh failure warns without rollback.
- Tests pin strict workspace mutation, selected-workspace settings reads,
language events, generation closure during sync, and workspace-scope replay
before a late Managed channel's first session.
- A settings read-back failure after a committed write warns without claiming a
persistence failure or syncing an unverified locale. Cold workspace language
overrides survive subsequent user changes and output-refresh requests.
- The shared behavior covers Desktop and the current VS Code Web Shell host.
Native packaged-client acceptance is reported separately from fixture tests.
@@ -0,0 +1,57 @@
# 无会话欢迎页设置
[English](sessionless-welcome-settings.md) | [简体中文](sessionless-welcome-settings.zh-CN.md)
## 问题
Web Shell 在创建对话前显示欢迎页。欢迎页可能发布目录中不存在的已保存模型,
而认证时会回退到已配置的聊天模型。当前切换 UI 语言通过发送斜杠提示词实现,
会创建对话并重新应用失效模型。没有配置任何连接的用户也必须能够切换 UI 语言。
## 决策
保留现有 Core 认证回退和持久化策略。目录中找不到模型时,欢迎页通过相同的 Core
同步逻辑解析预览,不执行认证、网络请求或配置写入。保留显式手动连接,以及
已注册模型现有的端点选择行为。
UI 语言切换改用无会话接口。复用进程全局的用户语言接口;新增受信任、归属于
所选工作区的语言接口,只保存该工作区并同步其已经运行的通道。不启动冷运行时,
后续启动时读取已保存配置。两条路径均保留输出语言和模型选择,保留 generation
及客户端归属校验。无法解析的工作区不得回退到主工作区。
SSH 工作区的设置和 agent 运行时仍归属于本地注册的工作区锚点,与现有工作区设置
接口一致。允许语言接口通过 SSH 边界,不写入远端项目文件。
Web Shell 设置选择器和本地 `/language ui` 命令共用此路径。保留用户/工作区作用域、
宿主语言控制权和错误回滚。仅在会话已经存在时刷新会话命令元数据。
Live 会话的工作区 UI 语言沿用设置 provider 绑定的工作区,与其他设置项一致。
无法解析目标时拒绝写入;输入框的工作区解析仍不为 Live 和 standalone 会话提供
普通项目目标。
作用域参数可以放在 UI 语言前后,与 CLI 一致;同时提供 `--project` 和 `--global`
仍然无效。写入成功后采用响应中的实际生效语言,并保留显式宿主语言的控制权。
实时同步部分失败时显示警告,不回滚已保存的选择。工作区校验分别报告语言和
不支持的同步参数错误,提供错误说明,并在语言无效时返回可用语言。
写入已提交后若无法重新读取配置,记录原因并返回已保存的请求及同步失败摘要,
不向运行时同步或发布未经确认的语言。generation 关闭仍返回 503。冷配对 Bridge
在后续用户级写入时保留工作区语言,同时保留最新的输出语言刷新请求,供较晚启动
的 Managed 通道使用。
## 验收
- 保存模型已失效且目录只有 preview 时,欢迎页与首次对话选择一致,不改写
`model.name`。
- 已注册模型及其端点保持不变;手动配置的运行时连接不会被无关目录模型替换。
- 无模型、无会话时可以切 UI 语言;不创建对话、选择模型、请求模型接口或改变
输出语言。
- 用户级写入用户配置;工作区级仅写入解析出的受信任工作区,包括次工作区。
无效或已移除的工作区应失败,不能修改主工作区或用户配置。
- Live 会话的设置页工作区语言选择器和项目级语言命令使用相同的绑定工作区,
不创建会话或发送提示词。Standalone 会话仍拒绝项目级命令。
- UI 语言命令接受作用域参数的两种位置;被覆盖的选择采用返回的实际生效语言。
部分同步失败时警告,不回滚已保存的值。
- 测试固定工作区 strict 写入、所选工作区的设置读取、语言事件、同步期间
generation 关闭,以及较晚启动的 Managed 通道在首个会话前重放工作区作用域。
- 写入成功后的配置回读失败应提示同步失败,不误报持久化失败,也不同步未经确认
的语言。冷工作区的语言覆盖在后续用户级变更和输出刷新请求后仍被保留。
- 桌面和当前 VS Code 的 Web Shell 宿主共用行为。原生安装包验收与 fixture 测试
分别报告。
@@ -133,6 +133,7 @@ operations when they share ownership and an SDK family.
| Streaming side questions | [`POST /session/:id/btw/stream`](./qwen-serve-protocol.md#post-sessionidbtwstream) · [`DELETE /session/:id/btw/stream/:requestId`](./qwen-serve-protocol.md#delete-sessionidbtwstreamrequestid) | `session_btw_stream`; live-session-owner | `DaemonClient.streamSessionBtw`, raw REST for cancellation |
| Optional session controls | [`POST /session/:id/recap`](./qwen-serve-protocol.md#post-sessionidrecap) · [`POST /session/:id/generate`](./qwen-serve-protocol.md#post-sessionidgenerate) · [`POST /session/:id/approval-mode`](./qwen-serve-protocol.md#post-sessionidapproval-mode) | `session_recap`, `session_generation`, `session_approval_mode_control`; live-session-owner | `DaemonClient.recapSession`, raw REST for generation, `DaemonClient.setSessionApprovalMode` |
| Workspace configuration | [`POST /workspace/tools/:name/enable`](./qwen-serve-protocol.md#post-workspacetoolsnameenable) · [`POST /workspace/skills/:name/enable`](./qwen-serve-protocol.md#post-workspaceskillsnameenable) · [`POST /workspace/skills/enable`](./qwen-serve-protocol.md#post-workspaceskillsenable) · [`POST /workspace/init`](./qwen-serve-protocol.md#post-workspaceinit) · [`POST /workspace/mcp/reload`](./qwen-serve-protocol.md#post-workspacemcpreload) · [`POST /workspace/mcp/:server/restart`](./qwen-serve-protocol.md#post-workspacemcpserverrestart) · [`POST /language`](./qwen-serve-protocol.md#post-language) | `workspace_tool_toggle`, `workspace_skill_settings_toggle`, `workspace_skill_settings_batch_toggle`, `workspace_init`, `workspace_mcp_manage`, `workspace_mcp_restart`, `user_language_sync`; legacy-primary or process-global | `DaemonClient.setWorkspaceToolEnabled`, `DaemonClient.setWorkspaceSkillEnabled`, `DaemonClient.setWorkspaceSkillsEnabled`, `DaemonClient.initWorkspace`, `DaemonClient.reloadWorkspaceMcp`, `DaemonClient.restartMcpServer`, `setUserLanguage` |
| Workspace UI language | [`POST /workspaces/:workspace/language`](./qwen-serve-protocol.md#post-workspacesworkspacelanguage) | `workspace_settings`; selected-runtime, trusted workspace | `WorkspaceDaemonClient.setUiLanguage` |
| Skill runtime refresh | [`POST /workspace/skills/refresh`](./qwen-serve-protocol.md#post-workspaceskillsrefresh) | `workspace_session_refresh`; legacy-primary | `DaemonClient.refreshWorkspaceSkills` |
| Device-flow authentication | [`POST /workspace/auth/device-flow`](./qwen-serve-protocol.md#post-workspaceauthdevice-flow) · [`GET /workspace/auth/device-flow/:id`](./qwen-serve-protocol.md#get-workspaceauthdevice-flowid) · [`DELETE /workspace/auth/device-flow/:id`](./qwen-serve-protocol.md#delete-workspaceauthdevice-flowid) · [`GET /workspace/auth/status`](./qwen-serve-protocol.md#get-workspaceauthstatus) | `auth_device_flow`; legacy-primary | `DaemonClient.startDeviceFlow`, `getDeviceFlow`, `cancelDeviceFlow`, `getAuthStatus` |
+8
View File
@@ -4557,6 +4557,14 @@ Errors (non-2xx):
SSE events (workspace-scoped): `mcp_server_restarted` with `{serverName, durationMs, originatorClientId?}` on success; `mcp_server_restart_refused` with `{serverName, reason, originatorClientId?}` on soft skip.
#### `POST /workspaces/:workspace/language`
Sessionless workspace UI-language sync, registered when settings persistence is available. Ownership: selected-runtime. Resolves the workspace id or encoded absolute cwd, requires an active trusted runtime and validates client ownership against that runtime. Inactive generations return `503 workspace_runtime_unavailable`; unresolved selectors never fall back to the primary workspace.
Request: `{ "language": "zh" }`. The language uses the same supported codes as `POST /language`. Persists only the selected workspace's `general.language`, then reads its effective merged setting and synchronizes already-live channels through `qwen/control/user/language` with `scope: "workspace"`. Cold channels are skipped without starting a process or creating a conversation. Output language and model settings remain unchanged; `syncOutputLanguage: true` is rejected.
The response has the same shape as `POST /language`, with `outputLanguage: null`; `language` is the effective configured locale (including `auto`) and `refresh` counts the selected runtime only. A workspace `language_changed` event is emitted on success. The SDK exposes `client.workspaceByCwd(cwd).setUiLanguage(language)` over REST, independently of the session transport.
#### `POST /language`
Capability tag: `user_language_sync` (conditional — advertised only when settings persistence is available, same condition as `workspace_settings`). Bridge → ACP extMethod `qwen/control/user/language` per trusted runtime with a live channel.
+10 -2
View File
@@ -3244,11 +3244,19 @@ describe('createAcpSessionBridge', () => {
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await expect(
bridge.setUserLanguage({ language: 'zh', syncOutputLanguage: true }),
bridge.setUserLanguage({
language: 'zh',
syncOutputLanguage: false,
scope: 'workspace',
}),
).resolves.toEqual({ language: 'zh', sessions: 2, failed: 0 });
expect(handle.agent.extMethodCalls).toContainEqual({
method: SERVE_CONTROL_EXT_METHODS.userLanguage,
params: { language: 'zh', syncOutputLanguage: true },
params: {
language: 'zh',
syncOutputLanguage: false,
scope: 'workspace',
},
});
await bridge.shutdown();
+2
View File
@@ -2813,6 +2813,8 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
setUserLanguage(params: {
language: string;
syncOutputLanguage: boolean;
/** Reload workspace settings too after a workspace-owned UI language write. */
scope?: 'workspace';
}): Promise<{
language: string;
sessions: number;
+101 -29
View File
@@ -4289,39 +4289,111 @@ describe('paired per-engine operations', () => {
);
});
it('sends the remembered language to a Managed channel before its first session', async () => {
const log: string[] = [];
it.each([undefined, 'workspace'] as const)(
'sends the remembered language and scope (%s) before the first Managed session',
async (scope) => {
const log: string[] = [];
const managedLanguage = vi.fn(refreshed(0));
const languageRequest = scope
? { ...request, syncOutputLanguage: false, scope }
: request;
const p = paired(
{},
answering('legacy', log, refreshed(0)),
answering('managed', log, managedLanguage),
);
await expect(
p.bridge.setUserLanguage(languageRequest),
).rejects.toBeInstanceOf(SessionNotFoundError);
await p.bridge.preheat();
await expect(
p.bridge.setUserLanguage(languageRequest),
).resolves.toMatchObject({ sessions: 0 });
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(log).toEqual([
'legacy:userLanguage:zh',
'managed:userLanguage:zh',
'managed:newSession',
'managed:newSession',
]);
expect(managedLanguage).toHaveBeenCalledExactlyOnceWith(
languageRequest,
);
// A channel that was live for the change is not sent it twice.
await p.bridge.setUserLanguage({
language: 'en',
syncOutputLanguage: false,
});
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(log.slice(4)).toEqual([
expect.stringMatching(/^(legacy|managed):userLanguage:en$/),
expect.stringMatching(/^(legacy|managed):userLanguage:en$/),
'managed:newSession',
]);
},
);
it.each([false, true])(
'keeps a cold workspace locale across user updates with output sync %s',
async (syncOutputLanguage) => {
const log: string[] = [];
const managedLanguage = vi.fn(refreshed(0));
const p = paired(
{},
answering('legacy', log, refreshed(0)),
answering('managed', log, managedLanguage),
);
await expect(
p.bridge.setUserLanguage({
language: 'zh',
syncOutputLanguage: false,
scope: 'workspace',
}),
).rejects.toBeInstanceOf(SessionNotFoundError);
for (const language of ['en', 'ru']) {
await expect(
p.bridge.setUserLanguage({ language, syncOutputLanguage }),
).rejects.toBeInstanceOf(SessionNotFoundError);
}
await p.bridge.loadSession({
workspaceCwd: WS_A,
sessionId: 'persisted',
});
expect(managedLanguage.mock.calls.at(-1)?.[0]).toMatchObject({
language: 'zh',
scope: 'workspace',
});
expect(
managedLanguage.mock.calls.some(
([params]) => params['syncOutputLanguage'] === true,
),
).toBe(syncOutputLanguage);
expect(log.at(-1)).toBe('managed:loadSession:persisted');
},
);
it('replays the latest cold user locale when no workspace override exists', async () => {
const managedLanguage = vi.fn(refreshed(0));
const p = paired(
{},
answering('legacy', log, refreshed(0)),
answering('managed', log, refreshed(0)),
engineChannel('legacy'),
answering('managed', [], managedLanguage),
);
await expect(p.bridge.setUserLanguage(request)).rejects.toBeInstanceOf(
SessionNotFoundError,
);
await p.bridge.preheat();
await expect(p.bridge.setUserLanguage(request)).resolves.toMatchObject({
sessions: 0,
for (const language of ['en', 'ru']) {
await expect(
p.bridge.setUserLanguage({
language,
syncOutputLanguage: true,
}),
).rejects.toBeInstanceOf(SessionNotFoundError);
}
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(managedLanguage).toHaveBeenCalledExactlyOnceWith({
language: 'ru',
syncOutputLanguage: true,
});
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(log).toEqual([
'legacy:userLanguage:zh',
'managed:userLanguage:zh',
'managed:newSession',
'managed:newSession',
]);
// A channel that was live for the change is not sent it twice.
await p.bridge.setUserLanguage({
language: 'en',
syncOutputLanguage: false,
});
await p.bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(log.slice(4)).toEqual([
expect.stringMatching(/^(legacy|managed):userLanguage:en$/),
expect.stringMatching(/^(legacy|managed):userLanguage:en$/),
'managed:newSession',
]);
});
it('sends the remembered language before a Managed restore and its deadline', async () => {
@@ -6941,17 +6941,22 @@ export function createSessionControlPlane(
// re-reads the persisted settings when it starts; a Managed channel that
// starts later is sent this before its first session request instead.
let rememberedUserLanguage:
| { language: string; syncOutputLanguage: boolean }
| { language: string; syncOutputLanguage: boolean; scope?: 'workspace' }
| undefined;
const sendUserLanguage = (
info: ChannelInfo,
params: { language: string; syncOutputLanguage: boolean },
params: {
language: string;
syncOutputLanguage: boolean;
scope?: 'workspace';
},
): Promise<{ language: string; sessions: number; failed: number }> => {
const response = withTimeout(
raceLongLived(
info.connection.extMethod(SERVE_CONTROL_EXT_METHODS.userLanguage, {
language: params.language,
syncOutputLanguage: params.syncOutputLanguage,
...(params.scope ? { scope: params.scope } : {}),
}),
getChannelClosedReject(info),
),
@@ -14872,9 +14877,16 @@ export function createSessionControlPlane(
async setUserLanguage(params) {
if (executionEngines) {
// A user write cannot clear a persisted workspace language override.
const workspaceOverride =
params.scope !== 'workspace' &&
rememberedUserLanguage?.scope === 'workspace'
? rememberedUserLanguage
: undefined;
rememberedUserLanguage = {
language: params.language,
language: workspaceOverride?.language ?? params.language,
syncOutputLanguage: params.syncOutputLanguage,
...(params.scope || workspaceOverride ? { scope: 'workspace' } : {}),
};
}
// Sessionless: runs on every channel that is already live — one per
@@ -38987,50 +38987,63 @@ describe('sessionLanguage multi-session propagation', () => {
await agentPromise;
});
it('userLanguage: accepts auto without any session and performs no writes', async () => {
const settings = {
merged: { mcpServers: {} },
reloadScopeFromDisk: vi.fn(),
getSystemHooks: vi.fn().mockReturnValue(undefined),
getUserHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
} as unknown as LoadedSettings;
it.each([undefined, 'workspace'] as const)(
'userLanguage: accepts auto without sessions or writes (scope=%s)',
async (scope) => {
const settings = {
merged: { mcpServers: {} },
reloadScopeFromDisk: vi.fn(),
getSystemHooks: vi.fn().mockReturnValue(undefined),
getUserHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
} as unknown as LoadedSettings;
const agentPromise = runAcpAgent(
makeConfig() as unknown as Config,
settings,
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
});
const agentPromise = runAcpAgent(
makeConfig() as unknown as Config,
settings,
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
});
vi.mocked(updateOutputLanguageFile).mockClear();
vi.mocked(writeOutputLanguageAndRegisterPath).mockClear();
vi.mocked(updateOutputLanguageFile).mockClear();
vi.mocked(writeOutputLanguageAndRegisterPath).mockClear();
const result = await agent.extMethod('qwen/control/user/language', {
language: 'auto',
syncOutputLanguage: true,
});
const result = await agent.extMethod('qwen/control/user/language', {
language: 'auto',
syncOutputLanguage: true,
...(scope ? { scope } : {}),
});
// Zero sessions is a success, and the daemon process owns persistence:
// the runtime must not write settings or output-language files itself.
expect(result).toEqual({ language: 'zh', sessions: 0, failed: 0 });
expect(vi.mocked(setLanguageAsync)).toHaveBeenCalledWith('auto');
expect(settings.reloadScopeFromDisk).toHaveBeenCalledWith(
SettingScope.User,
);
expect(vi.mocked(updateOutputLanguageFile)).not.toHaveBeenCalled();
expect(
vi.mocked(writeOutputLanguageAndRegisterPath),
).not.toHaveBeenCalled();
// Zero sessions is a success, and the daemon process owns persistence:
// the runtime must not write settings or output-language files itself.
expect(result).toEqual({ language: 'zh', sessions: 0, failed: 0 });
expect(vi.mocked(setLanguageAsync)).toHaveBeenCalledWith('auto');
expect(settings.reloadScopeFromDisk).toHaveBeenCalledWith(
SettingScope.User,
);
if (scope === 'workspace') {
expect(settings.reloadScopeFromDisk).toHaveBeenCalledWith(
SettingScope.Workspace,
);
} else {
expect(settings.reloadScopeFromDisk).not.toHaveBeenCalledWith(
SettingScope.Workspace,
);
}
expect(vi.mocked(updateOutputLanguageFile)).not.toHaveBeenCalled();
expect(
vi.mocked(writeOutputLanguageAndRegisterPath),
).not.toHaveBeenCalled();
mockConnectionState.resolve();
await agentPromise;
});
mockConnectionState.resolve();
await agentPromise;
},
);
it('userLanguage: wraps UI-language switch failures', async () => {
const settings = {
@@ -13491,6 +13491,9 @@ class QwenAgent implements Agent {
// Pick up what the daemon just persisted so this process stops
// serving a stale merged view.
this.settings.reloadScopeFromDisk(SettingScope.User);
if (params['scope'] === 'workspace') {
this.settings.reloadScopeFromDisk(SettingScope.Workspace);
}
// UI-language-only switches do not change the system instruction;
// refresh sessions only when the output language moved.
@@ -538,6 +538,7 @@ describe.skipIf(process.platform === 'win32')(
for (const suffix of [
'/acp',
'/voice',
'/language',
'/voice/transcribe',
'/trust/request',
'/trust/grant',
@@ -61,7 +61,7 @@ function isSshPassthroughRoute(req: Request): boolean {
req.path,
);
case 'POST':
return /^\/(?:acp|voice(?:\/transcribe)?|file\/(?:write|edit|upload)|trust\/(?:request|grant)|runtime\/(?:ensure|stop)|permissions|settings|sessions\/(?:delete|archive|unarchive)|session-groups)$/.test(
return /^\/(?:acp|voice(?:\/transcribe)?|file\/(?:write|edit|upload)|trust\/(?:request|grant)|runtime\/(?:ensure|stop)|permissions|settings|language|sessions\/(?:delete|archive|unarchive)|session-groups)$/.test(
req.path,
);
case 'PATCH':
@@ -8,10 +8,19 @@ import { beforeEach, describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { registerUserLanguageRoutes } from './user-language.js';
import { SettingScope } from '../../config/settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { SessionNotFoundError } from '../acp-session-bridge.js';
import { WorkspaceGenerationClosedError } from '../workspace-registry.js';
import { setLanguageAsync, getCurrentLanguage } from '../../i18n/index.js';
import { updateOutputLanguageFile } from '../../i18n/languageUtils.js';
import { FatalConfigError } from '@qwen-code/qwen-code-core/utils/errors.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
vi.mock('../../utils/stdioHelpers.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/stdioHelpers.js')>();
return { ...actual, writeStderrLine: vi.fn() };
});
vi.mock('../../i18n/index.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../i18n/index.js')>();
@@ -36,6 +45,15 @@ vi.mock('../../i18n/languageUtils.js', async (importOriginal) => {
const LANGUAGE_CODES = ['en', 'zh', 'auto'];
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(() => ({ merged: { general: {} } })),
};
});
function makeRuntime(overrides: {
trusted: boolean;
cwd: string;
@@ -47,7 +65,9 @@ function makeRuntime(overrides: {
return {
trusted: overrides.trusted,
workspaceCwd: overrides.cwd,
generationGuard: { assertOpen: vi.fn() },
bridge: {
knownClientIds: vi.fn(() => new Set<string>()),
setUserLanguage: vi.fn(
overrides.setUserLanguage ??
(async (params: { language: string }) => ({
@@ -65,6 +85,12 @@ function makeApp(
overrides: {
runtimes?: Array<ReturnType<typeof makeRuntime>>;
persistSetting?: ReturnType<typeof vi.fn>;
entryState?:
| 'active'
| 'draining'
| 'transitioning'
| 'blocked'
| 'removed';
parseAndValidateClientId?: (
req: express.Request,
res: express.Response,
@@ -77,29 +103,423 @@ function makeApp(
const persistSetting =
overrides.persistSetting ?? vi.fn(async () => undefined);
const runtimes = overrides.runtimes ?? [];
const mutate = vi.fn(
(): express.RequestHandler => (_req, _res, next) => next(),
);
registerUserLanguageRoutes(app, {
boundWorkspace: '/workspace',
mutate: () => (_req, _res, next) => next(),
mutate,
safeBody: (req) =>
req.body && typeof req.body === 'object' ? req.body : {},
languageCodes: LANGUAGE_CODES,
persistSetting,
workspaceRegistry: { list: () => runtimes } as never,
workspaceRegistry: {
list: () => runtimes,
getEntryByWorkspaceId: (id: string) => {
const runtime = runtimes.find((r) => r.workspaceCwd === `/${id}`);
return runtime
? { state: overrides.entryState ?? 'active', current: { runtime } }
: undefined;
},
} as never,
parseAndValidateClientId:
overrides.parseAndValidateClientId ?? (() => undefined),
});
return { app, persistSetting, runtimes };
return { app, persistSetting, runtimes, mutate };
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadSettings).mockReturnValue({
merged: { general: {} },
} as ReturnType<typeof loadSettings>);
vi.mocked(setLanguageAsync).mockReset().mockResolvedValue(undefined);
vi.mocked(getCurrentLanguage).mockReturnValue('zh');
});
describe('POST /language', () => {
it.each(['draining', 'transitioning', 'blocked', 'removed'] as const)(
'rejects a %s workspace without side effects',
async (entryState) => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app, persistSetting } = makeApp({
runtimes: [runtime],
entryState,
});
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(503);
expect(persistSetting).not.toHaveBeenCalled();
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
},
);
it('rejects a closed runtime generation before writing settings', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
runtime.generationGuard.assertOpen.mockImplementation(() => {
throw new WorkspaceGenerationClosedError();
});
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(503);
expect(persistSetting).not.toHaveBeenCalled();
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
});
it('does not sync a replaced generation after persistence', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const persistSetting = vi.fn(async () => {
runtime.generationGuard.assertOpen.mockImplementation(() => {
throw new WorkspaceGenerationClosedError();
});
});
const { app } = makeApp({ runtimes: [runtime], persistSetting });
expect(
(
await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' })
).status,
).toBe(503);
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(runtime.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
});
it('does not publish after the generation closes during language sync', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
runtime.bridge.setUserLanguage.mockImplementation(async ({ language }) => {
runtime.generationGuard.assertOpen.mockImplementation(() => {
throw new WorkspaceGenerationClosedError();
});
return { language, sessions: 1, failed: 0 };
});
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(503);
expect(persistSetting).toHaveBeenCalledOnce();
expect(runtime.bridge.setUserLanguage).toHaveBeenCalledOnce();
expect(runtime.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
});
it('validates client ownership against the selected workspace', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
const res = await request(app)
.post('/workspaces/secondary/language')
.set('x-qwen-client-id', 'foreign-client')
.send({ language: 'zh' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_client_id');
expect(persistSetting).not.toHaveBeenCalled();
});
it.each(['selected', 'sibling'] as const)(
'checks a known %s-workspace client against the addressed bridge',
async (registration) => {
const primary = makeRuntime({ trusted: true, cwd: '/primary' });
const secondary = makeRuntime({ trusted: true, cwd: '/secondary' });
const owner = registration === 'selected' ? secondary : primary;
owner.bridge.knownClientIds.mockReturnValue(new Set(['client-1']));
const { app, persistSetting } = makeApp({
runtimes: [primary, secondary],
});
const res = await request(app)
.post('/workspaces/secondary/language')
.set('x-qwen-client-id', 'client-1')
.send({ language: 'zh' });
if (registration === 'selected') {
expect(res.status).toBe(200);
expect(persistSetting).toHaveBeenCalledOnce();
expect(
secondary.bridge.publishWorkspaceEvent,
).toHaveBeenCalledExactlyOnceWith({
type: 'language_changed',
data: { language: 'zh', outputLanguage: null },
originatorClientId: 'client-1',
});
} else {
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_client_id');
expect(persistSetting).not.toHaveBeenCalled();
expect(secondary.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(secondary.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
}
expect(primary.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(primary.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
},
);
it('uses the effective workspace locale after a higher-priority override', async () => {
vi.mocked(loadSettings).mockReturnValue({
merged: { general: { language: 'en' } },
} as ReturnType<typeof loadSettings>);
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app } = makeApp({ runtimes: [runtime] });
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(200);
expect(res.body.language).toBe('en');
expect(runtime.bridge.setUserLanguage).toHaveBeenCalledWith({
language: 'en',
syncOutputLanguage: false,
scope: 'workspace',
});
});
it('does not report a persisted write as failed when event delivery fails', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
runtime.bridge.publishWorkspaceEvent.mockImplementation(() => {
throw new Error('closed listener');
});
const { app } = makeApp({ runtimes: [runtime] });
expect(
(
await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' })
).status,
).toBe(200);
});
it('does not synchronize a locale when persistence fails', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app } = makeApp({
runtimes: [runtime],
persistSetting: vi.fn().mockRejectedValue(new Error('disk full')),
});
expect(
(
await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' })
).status,
).toBe(500);
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(runtime.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('disk full'),
);
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('/secondary'),
);
});
it('distinguishes a failed read-back from an already committed settings write', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
vi.mocked(loadSettings).mockImplementation(() => {
throw new FatalConfigError('invalid post-write settings');
});
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(persistSetting).toHaveBeenCalledOnce();
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('persist_error');
expect(res.body).toMatchObject({
language: 'zh',
outputLanguage: null,
refresh: { runtimes: 0, sessions: 0, failed: 1 },
});
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(runtime.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('invalid post-write settings'),
);
});
it('keeps the generation fence when post-write settings reload fails', async () => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
vi.mocked(loadSettings).mockImplementation(() => {
runtime.generationGuard.assertOpen.mockImplementation(() => {
throw new WorkspaceGenerationClosedError();
});
throw new FatalConfigError('invalid post-write settings');
});
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(503);
expect(persistSetting).toHaveBeenCalledOnce();
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(runtime.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
});
it.each(['transport-error', 'partial-refresh'] as const)(
'reports workspace %s without losing the persisted write or event',
async (failure) => {
const primary = makeRuntime({ trusted: true, cwd: '/primary' });
const secondary = makeRuntime({ trusted: true, cwd: '/secondary' });
if (failure === 'transport-error') {
secondary.bridge.setUserLanguage.mockRejectedValue(
new Error('transport closed'),
);
} else {
secondary.bridge.setUserLanguage.mockResolvedValue({
language: 'zh',
sessions: 2,
failed: 1,
});
}
const { app, persistSetting } = makeApp({
runtimes: [primary, secondary],
});
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(200);
expect(res.body.refresh).toEqual({
runtimes: failure === 'transport-error' ? 0 : 1,
sessions: failure === 'transport-error' ? 0 : 2,
failed: 1,
});
expect(persistSetting).toHaveBeenCalledExactlyOnceWith(
'/secondary',
SettingScope.Workspace,
'general.language',
'zh',
expect.any(Function),
);
expect(
secondary.bridge.publishWorkspaceEvent,
).toHaveBeenCalledExactlyOnceWith({
type: 'language_changed',
data: { language: 'zh', outputLanguage: null },
});
expect(primary.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(primary.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
expect(updateOutputLanguageFile).not.toHaveBeenCalled();
},
);
it('persists a secondary workspace UI language without touching user settings or siblings', async () => {
const primary = makeRuntime({ trusted: true, cwd: '/primary' });
const secondary = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app, persistSetting, mutate } = makeApp({
runtimes: [primary, secondary],
});
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'zh' });
expect(res.status).toBe(200);
expect(mutate).toHaveBeenCalledWith({ strict: true });
expect(persistSetting).toHaveBeenCalledExactlyOnceWith(
'/secondary',
SettingScope.Workspace,
'general.language',
'zh',
expect.any(Function),
);
expect(secondary.bridge.setUserLanguage).toHaveBeenCalledWith({
language: 'zh',
syncOutputLanguage: false,
scope: 'workspace',
});
expect(loadSettings).toHaveBeenCalledExactlyOnceWith('/secondary', {
skipLoadEnvironment: true,
workspaceTrusted: true,
});
expect(
secondary.bridge.publishWorkspaceEvent,
).toHaveBeenCalledExactlyOnceWith({
type: 'language_changed',
data: { language: 'zh', outputLanguage: null },
});
expect(primary.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(primary.bridge.publishWorkspaceEvent).not.toHaveBeenCalled();
expect(updateOutputLanguageFile).not.toHaveBeenCalled();
expect(setLanguageAsync).not.toHaveBeenCalled();
});
it('accepts a cold workspace without starting an ACP channel', async () => {
const runtime = makeRuntime({
trusted: true,
cwd: '/secondary',
setUserLanguage: async () => {
throw new SessionNotFoundError('user-language');
},
});
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'en' });
expect(res.status).toBe(200);
expect(res.body.refresh).toEqual({ runtimes: 0, sessions: 0, failed: 0 });
expect(persistSetting).toHaveBeenCalledOnce();
});
it('rejects untrusted workspace writes', async () => {
const { app, persistSetting } = makeApp({
runtimes: [makeRuntime({ trusted: false, cwd: '/secondary' })],
});
expect(
(
await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'en' })
).status,
).toBe(403);
expect(persistSetting).not.toHaveBeenCalled();
});
it('rejects an unknown workspace instead of writing primary settings', async () => {
const { app, persistSetting } = makeApp();
expect(
(
await request(app)
.post('/workspaces/missing/language')
.send({ language: 'en' })
).status,
).toBe(400);
expect(persistSetting).not.toHaveBeenCalled();
});
it.each([true, 'yes', null])(
'rejects unsupported workspace output-language sync (%s)',
async (syncOutputLanguage) => {
const runtime = makeRuntime({ trusted: true, cwd: '/secondary' });
const { app, persistSetting } = makeApp({ runtimes: [runtime] });
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language: 'en', syncOutputLanguage });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_sync_flag');
expect(res.body.error).toEqual(expect.any(String));
expect(persistSetting).not.toHaveBeenCalled();
expect(runtime.bridge.setUserLanguage).not.toHaveBeenCalled();
expect(updateOutputLanguageFile).not.toHaveBeenCalled();
},
);
it.each(['xx', undefined, 123])(
'reports the allowed workspace UI languages for invalid input (%s)',
async (language) => {
const { app, persistSetting } = makeApp({
runtimes: [makeRuntime({ trusted: true, cwd: '/secondary' })],
});
const res = await request(app)
.post('/workspaces/secondary/language')
.send({ language });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_language');
expect(res.body.allowed).toEqual(LANGUAGE_CODES);
expect(res.body.error).toEqual(expect.any(String));
expect(persistSetting).not.toHaveBeenCalled();
},
);
it('rejects an unknown language code without persisting', async () => {
const { app, persistSetting } = makeApp();
+128 -1
View File
@@ -5,7 +5,7 @@
*/
import type { Application, Request, RequestHandler, Response } from 'express';
import { SettingScope } from '../../config/settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { getCurrentLanguage, setLanguageAsync } from '../../i18n/index.js';
import {
resolveOutputLanguageOrPreserveAuto,
@@ -14,6 +14,11 @@ import {
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { SessionNotFoundError } from '../acp-session-bridge.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
import {
resolveTrustedRuntime,
sendGenerationClosedError,
} from '../workspace-route-runtime.js';
import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js';
export interface UserLanguageRouteDeps {
/**
@@ -30,6 +35,7 @@ export interface UserLanguageRouteDeps {
scope: SettingScope,
key: string,
value: unknown,
assertGenerationOpen?: () => void,
) => Promise<unknown>;
workspaceRegistry: WorkspaceRegistry;
parseAndValidateClientId: (
@@ -56,6 +62,127 @@ export function registerUserLanguageRoutes(
app: Application,
deps: UserLanguageRouteDeps,
): void {
// Selected-runtime scoped; cold runtimes read the persisted locale at startup.
app.post(
'/workspaces/:workspace/language',
deps.mutate({ strict: true }),
async (req, res) => {
const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res);
if (!runtime) return;
const body = deps.safeBody(req);
const language = body['language'];
if (
typeof language !== 'string' ||
!deps.languageCodes.includes(language)
) {
res.status(400).json({
error:
'`language` is required and must be one of: ' +
deps.languageCodes.join(', '),
code: 'invalid_language',
allowed: deps.languageCodes,
});
return;
}
if (
body['syncOutputLanguage'] !== undefined &&
body['syncOutputLanguage'] !== false
) {
res.status(400).json({
error:
'`syncOutputLanguage` must be false when provided on this route',
code: 'invalid_sync_flag',
});
return;
}
const clientId = parseAndValidateWorkspaceClientId(
req,
res,
runtime.bridge,
);
if (clientId === null) return;
const assertGenerationOpen = () => runtime.generationGuard?.assertOpen();
try {
assertGenerationOpen();
await deps.persistSetting(
runtime.workspaceCwd,
SettingScope.Workspace,
'general.language',
language,
assertGenerationOpen,
);
assertGenerationOpen();
let effective: string;
try {
const configuredLanguage = loadSettings(runtime.workspaceCwd, {
skipLoadEnvironment: true,
workspaceTrusted: true,
}).merged.general?.language;
effective =
typeof configuredLanguage === 'string'
? configuredLanguage
: language;
} catch (error) {
assertGenerationOpen();
writeStderrLine(
`qwen serve: workspace language saved but settings reload failed for ${runtime.workspaceCwd}: ${String(error)}`,
);
res.status(200).json({
language,
outputLanguage: null,
refresh: { runtimes: 0, sessions: 0, failed: 1 },
});
return;
}
let failed = 0;
let refreshedRuntimes = 0;
let sessions = 0;
try {
const result = await runtime.bridge.setUserLanguage({
language: effective,
syncOutputLanguage: false,
scope: 'workspace',
});
refreshedRuntimes = 1;
sessions = result.sessions;
failed = result.failed;
} catch (error) {
if (!(error instanceof SessionNotFoundError)) {
failed = 1;
writeStderrLine(
`qwen serve: workspace language sync failed: ${String(error)}`,
);
}
}
assertGenerationOpen();
try {
runtime.bridge.publishWorkspaceEvent({
type: 'language_changed',
data: { language: effective, outputLanguage: null },
...(clientId ? { originatorClientId: clientId } : {}),
});
} catch (error) {
writeStderrLine(
`qwen serve: workspace language event publish failed: ${String(error)}`,
);
}
res.status(200).json({
language: effective,
outputLanguage: null,
refresh: { runtimes: refreshedRuntimes, sessions, failed },
});
} catch (error) {
if (sendGenerationClosedError(res, error)) return;
writeStderrLine(
`qwen serve: workspace language persist error for ${runtime.workspaceCwd}: ${String(error)}`,
);
res.status(500).json({
code: 'persist_error',
error: 'Failed to change workspace UI language',
});
}
},
);
app.post('/language', deps.mutate(), async (req, res) => {
const body = deps.safeBody(req);
const language = body['language'];
@@ -430,6 +430,13 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
expect(resolveDaemonTelemetryRoute(mockReq('POST', '/language'))).toEqual({
route: 'POST /language',
});
expect(
resolveDaemonTelemetryRoute(
mockReq('POST', '/workspaces/ws-secondary/language'),
),
).toEqual({
route: 'POST /workspaces/:workspace/language',
});
});
it.each([
@@ -762,6 +762,9 @@ export function resolveDaemonTelemetryRoute(
const pluralWorkspacePrefix = /^\/workspaces\/[^/]+/;
if (pluralWorkspacePrefix.test(path)) {
const suffix = path.replace(pluralWorkspacePrefix, '/workspace');
if (req.method === 'POST' && suffix === '/workspace/language') {
return { route: 'POST /workspaces/:workspace/language' };
}
if (req.method === 'GET') {
if (
suffix === '/workspace/mcp' ||
@@ -502,6 +502,61 @@ describe('createWorkspaceProvidersStatusProvider', () => {
},
);
it('previews the auth fallback for a missing saved model without rewriting settings', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
$version: 4,
security: { auth: { selectedType: 'openai' } },
model: { name: 'qwen3.8-max' },
modelProviders: {
openai: [
{
id: 'qwen3.8-max-preview',
name: '[Token Plan] preview',
baseUrl: 'https://token-plan.example/v1',
envKey: 'REPRO_PLAN_KEY',
},
],
},
});
const before = await fs.readFile(
path.join(qwenHome, 'settings.json'),
'utf8',
);
const result = await provider(workspace, false);
expect(result.current).toMatchObject({
authType: 'openai',
modelId: 'qwen3.8-max-preview(openai)',
baseUrl: 'https://token-plan.example/v1',
});
expect(
result.providers
.flatMap((entry) => entry.models)
.find((model) => model.isCurrent)?.baseModelId,
).toBe('qwen3.8-max-preview');
expect(
await fs.readFile(path.join(qwenHome, 'settings.json'), 'utf8'),
).toBe(before);
});
it('preserves a missing model backed by explicit connection settings', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
security: {
auth: {
selectedType: 'openai',
apiKey: 'manual-key',
baseUrl: 'https://manual.example/v1',
},
},
model: { name: 'manual-model' },
modelProviders: { openai: [{ id: 'other-model' }] },
});
expect((await provider(workspace, false)).current?.modelId).toBe(
'manual-model',
);
});
it('reads fresh default model settings on every request', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
@@ -111,22 +111,33 @@ function buildWorkspaceProvidersStatus(
generationConfigSources: resolvedCliConfig.sources,
});
const currentAuth = resolvedCliConfig.authType;
const currentModelId = (
let currentModelId = (
resolvedCliConfig.model ||
modelsConfig.getModel() ||
''
).trim();
const requestedModelId = currentModelId;
if (
currentAuth &&
!modelsConfig.getResolvedModel(currentAuth, currentModelId)
) {
modelsConfig.syncAfterAuthRefresh(currentAuth, currentModelId);
currentModelId = (modelsConfig.getModel() || '').trim();
}
const usedFallback = currentModelId !== requestedModelId;
const hasCurrentModel = currentModelId.length > 0;
const modelCameFromSettings =
!argv.model && settings.model?.name?.trim() === currentModelId;
const currentBaseUrl =
modelCameFromSettings && settings.model?.baseUrl !== undefined
const currentBaseUrl = usedFallback
? modelsConfig.getGenerationConfig().baseUrl
: modelCameFromSettings && settings.model?.baseUrl !== undefined
? settings.model.baseUrl || undefined
: resolvedCliConfig.sources['baseUrl']
? resolvedCliConfig.baseUrl || undefined
: undefined;
const currentRegistryBaseUrl =
modelCameFromSettings && currentAuth
const currentRegistryBaseUrl = usedFallback
? modelsConfig.getCurrentRegistryBaseUrl()
: modelCameFromSettings && currentAuth
? settings.model?.baseUrl !== undefined
? settings.model.baseUrl || null
: (modelsConfig.getResolvedModel(currentAuth, currentModelId)
@@ -6721,6 +6721,7 @@ export class DaemonClient {
'POST /language',
{
method: 'POST',
mode: 'rest',
body: {
language,
syncOutputLanguage: opts?.syncOutputLanguage ?? false,
@@ -8677,6 +8678,18 @@ export class WorkspaceDaemonClient {
);
}
setUiLanguage(
language: string,
clientId?: string,
): Promise<SetUserLanguageResult> {
return this.client.workspaceJsonRequest<SetUserLanguageResult>(
this.workspaceSelector,
'/language',
'POST /workspaces/:workspace/language',
{ method: 'POST', body: { language }, clientId, mode: 'rest' },
);
}
listWorkspaceAgents(): Promise<DaemonWorkspaceAgentsStatus> {
return this.get('/agents', 'GET /workspaces/:workspace/agents');
}
@@ -7430,6 +7430,51 @@ describe('DaemonClient', () => {
});
describe('setUserLanguage (#10234)', () => {
it('uses REST for sessionless locale changes even with an ACP transport', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
language: 'en',
outputLanguage: null,
refresh: { runtimes: 0, sessions: 0, failed: 0 },
}),
);
const transportFetch = vi
.fn()
.mockRejectedValue(new Error('No ACP mapping'));
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
transport: { fetch: transportFetch } as unknown as DaemonTransport,
});
await client.setUserLanguage('en');
await client.workspaceByCwd('/secondary').setUiLanguage('en');
expect(calls.map((call) => call.url)).toEqual([
'http://daemon/language',
'http://daemon/workspaces/%2Fsecondary/language',
]);
expect(transportFetch).not.toHaveBeenCalled();
});
it('writes UI language to the selected workspace without a session', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
language: 'zh',
outputLanguage: null,
refresh: { runtimes: 0, sessions: 0, failed: 0 },
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client
.workspaceByCwd('/secondary project')
.setUiLanguage('zh', 'client-1');
expect(calls[0]?.url).toBe(
'http://daemon/workspaces/%2Fsecondary%20project/language',
);
expect(calls[0]?.method).toBe('POST');
expect(JSON.parse(calls[0]!.body!)).toEqual({ language: 'zh' });
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
});
it('POSTs to the sessionless /language route and returns the typed result', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
+380 -26
View File
@@ -803,6 +803,11 @@ const {
refreshBrand: vi.fn(),
},
mockWorkspaceActions: {
setUiLanguage: vi.fn().mockImplementation(async (language: string) => ({
language,
outputLanguage: null,
refresh: { runtimes: 1, sessions: 1, failed: 0 },
})),
readWorkspaceFile: vi.fn().mockResolvedValue({
content: '',
truncated: false,
@@ -1766,7 +1771,6 @@ vi.mock('./components/messages/SettingsMessage', async () => {
{
'data-testid': 'change-language-workspace',
type: 'button',
// Workspace tab language change → /language ui en --project.
onClick: () => props.onLanguageChange?.('en', 'workspace'),
},
'language (workspace)',
@@ -12654,6 +12658,13 @@ beforeEach(() => {
mockStore.getSnapshot.mockClear();
mockStore.dispatch.mockClear();
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] });
mockWorkspaceActions.setUiLanguage
.mockReset()
.mockImplementation(async (language: string) => ({
language,
outputLanguage: null,
refresh: { runtimes: 1, sessions: 1, failed: 0 },
}));
mockWorkspaceActions.readWorkspaceFile.mockReset();
mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({
content: '',
@@ -27670,17 +27681,18 @@ describe('App session callbacks', () => {
await flush();
expect(accepted).toBe(true);
expect(
mockSessionActions.sendPrompt.mock.calls.some(
(c) => c[0] === '/language ui ru',
),
).toBe(true);
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'ru',
'user',
expect.any(String),
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
// The invalid-language toast is the gate rejecting the locale.
expect(onToast).not.toHaveBeenCalledWith(
'error',
expect.stringContaining('Invalid language'),
);
// handleLanguageChange runs on the sendPrompt success path.
// Notify the host after the sessionless settings write succeeds.
expect(onLanguageChange).toHaveBeenCalledWith('ru');
});
@@ -44186,13 +44198,19 @@ describe('App session callbacks', () => {
expect(qualifiedSetWorkspaceSetting).not.toHaveBeenCalled();
});
it('sends /language ui --project for a workspace-scoped language change from Settings', async () => {
it('syncs a workspace-scoped UI language without sending a prompt', async () => {
mockConnection.sessionContext = {
kind: 'workspace',
cwd: '/work/secondary',
};
const onLanguageChange = vi.fn();
const { container } = renderApp({ onLanguageChange });
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(mockConnection.sessionId).toBe('session-1');
mockSessionActions.refreshCommands.mockClear();
await act(async () => {
container
@@ -44204,14 +44222,208 @@ describe('App session callbacks', () => {
});
await flush();
expect(
mockSessionActions.sendPrompt.mock.calls.some(
(c) => c[0] === '/language ui en --project',
),
).toBe(true);
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'en',
'workspace',
'/work/secondary',
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(mockSessionActions.refreshCommands).toHaveBeenCalledOnce();
expect(onLanguageChange).not.toHaveBeenCalled();
});
it('maps a Chinese UI language choice to the daemon language code', async () => {
renderApp();
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('/language ui zh-CN --project');
await Promise.resolve();
});
await flush();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'zh',
'workspace',
expect.any(String),
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('targets the settings workspace for a Live UI language change', async () => {
mockConnection.sessionContext = { kind: 'live' };
mockConnection.workspaceCwd = '/tmp/live';
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'primary', cwd: '/tmp/project', primary: true, trusted: true },
{
id: 'live',
cwd: '/tmp/live',
primary: false,
trusted: true,
kind: 'live',
},
],
};
const onError = vi.fn();
const { container } = renderApp({ onError });
await flush();
await act(async () => {
const settingsButton = container.querySelector<HTMLButtonElement>(
'[data-testid="open-sidebar-settings"]',
);
expect(settingsButton).not.toBeNull();
settingsButton!.click();
});
await flush();
await act(async () => {
const languageButton = container.querySelector<HTMLButtonElement>(
'[data-testid="change-language-workspace"]',
);
expect(languageButton).not.toBeNull();
languageButton!.click();
await Promise.resolve();
});
await flush();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'en',
'workspace',
'/tmp/project',
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it('accepts project UI language changes in a Live session', async () => {
mockConnection.sessionContext = { kind: 'live' };
mockConnection.workspaceCwd = '/tmp/live';
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'primary', cwd: '/tmp/project', primary: true, trusted: true },
{
id: 'live',
cwd: '/tmp/live',
primary: false,
trusted: true,
kind: 'live',
},
],
};
const onToast = vi.fn();
renderApp({ onToast });
await flush();
act(() => {
expect(
testState.latestChatEditorProps?.onSubmit('/language ui en --project'),
).toBe(true);
});
await flush();
expect(onToast).not.toHaveBeenCalled();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'en',
'workspace',
'/tmp/project',
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it.each(['live', 'standalone'] as const)(
'refuses project UI language changes without a settings workspace (%s)',
async (kind) => {
mockConnection.sessionContext = { kind };
const onToast = vi.fn();
renderApp({ onToast });
await flush();
act(() => {
testState.latestChatEditorProps?.onSubmit('/language ui en --project');
});
await flush();
expect(onToast).toHaveBeenCalledWith(
'info',
'This action is available only in project conversations.',
);
expect(mockWorkspaceActions.setUiLanguage).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(mockSessionActions.createSession).not.toHaveBeenCalled();
},
);
it.each([
['/language ui --project en', 'en', 'workspace'],
['/language ui en --project', 'en', 'workspace'],
['/language ui --global ru', 'ru', 'user'],
['/language ui ru --global', 'ru', 'user'],
])(
'accepts UI language scope flags in %s',
async (command, language, scope) => {
const onToast = vi.fn();
renderApp({ onToast });
await flush();
await act(async () => {
expect(testState.latestChatEditorProps).toBeDefined();
testState.latestChatEditorProps?.onSubmit(command);
await Promise.resolve();
});
await flush();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
language,
scope,
expect.any(String),
);
expect(onToast).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
},
);
it.each([
'/language ui --project --global en',
'/language ui en --global --project',
])('rejects conflicting UI language scope flags in %s', async (command) => {
const onToast = vi.fn();
renderApp({ onToast });
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit(command);
await Promise.resolve();
});
await flush();
expect(onToast).toHaveBeenCalledWith(
'error',
expect.stringContaining('Usage:'),
);
expect(mockWorkspaceActions.setUiLanguage).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('documents UI language scope flags in local command usage', async () => {
renderApp();
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('/language');
await Promise.resolve();
});
await flush();
expect(mockStore.dispatch).toHaveBeenCalledWith([
expect.objectContaining({
type: 'status',
text: expect.stringContaining('[--project|--global]'),
}),
]);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('sends /language output for a model output language change from Settings', async () => {
const { container } = renderApp();
await flush();
@@ -44260,7 +44472,7 @@ describe('App session callbacks', () => {
).toBe(true);
});
it('does not name a session created by a settings language change', async () => {
it('does not create a session for a settings UI language change', async () => {
mockConnection.sessionId = undefined;
mockConnection.displayName = undefined;
const { container } = renderApp();
@@ -44279,12 +44491,15 @@ describe('App session callbacks', () => {
});
await flush();
expect(
mockSessionActions.sendPrompt.mock.calls.some(
(c) => c[0] === '/language ui en --project',
),
).toBe(true);
expect(mockSessionActions.createSession).toHaveBeenCalled();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'en',
'workspace',
expect.any(String),
);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(mockSessionActions.createSession).not.toHaveBeenCalled();
expect(mockSessionActions.setModel).not.toHaveBeenCalled();
expect(mockSessionActions.refreshCommands).not.toHaveBeenCalled();
expect(mockSessionActions.renameSession).not.toHaveBeenCalled();
});
@@ -44371,7 +44586,7 @@ describe('App session callbacks', () => {
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="change-language-workspace"]',
'[data-testid="change-output-language"]',
)
?.click();
await Promise.resolve();
@@ -49873,6 +50088,143 @@ describe('settings-derived theme and language (#11955)', () => {
expect(onLanguageResolved).not.toHaveBeenCalled();
});
it('adopts the applied workspace UI language after a higher-priority override', async () => {
testState.settings = [languageSetting('zh')];
mockWorkspaceActions.setUiLanguage.mockResolvedValueOnce({
language: 'zh',
outputLanguage: null,
refresh: { runtimes: 1, sessions: 1, failed: 0 },
});
const onLanguageResolved = vi.fn();
const onLanguageChange = vi.fn();
const { container } = renderApp({ onLanguageResolved, onLanguageChange });
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
await act(async () => {
const button = container.querySelector<HTMLButtonElement>(
'[data-testid="change-language-workspace"]',
);
expect(button).not.toBeNull();
button!.click();
await Promise.resolve();
});
await flush();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'en',
'workspace',
expect.any(String),
);
expect(
container.querySelector('[data-web-shell-root]')?.getAttribute('lang'),
).toBe('zh-CN');
expect(onLanguageResolved).toHaveBeenLastCalledWith('zh-CN');
expect(onLanguageChange).not.toHaveBeenCalled();
});
it('persists the applied user UI language through the host callback', async () => {
testState.settings = [languageSetting('en')];
mockWorkspaceActions.setUiLanguage.mockResolvedValueOnce({
language: 'zh',
outputLanguage: null,
refresh: { runtimes: 1, sessions: 1, failed: 0 },
});
const onLanguageResolved = vi.fn();
const onLanguageChange = vi.fn();
const { container } = renderApp({ onLanguageResolved, onLanguageChange });
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('/language ui ru');
await Promise.resolve();
});
await flush();
expect(
container.querySelector('[data-web-shell-root]')?.getAttribute('lang'),
).toBe('zh-CN');
expect(onLanguageResolved).toHaveBeenLastCalledWith('zh-CN');
expect(onLanguageChange).toHaveBeenCalledWith('zh-CN');
});
it('warns about partial UI language refresh without rolling back the saved choice', async () => {
testState.settings = [languageSetting('zh')];
mockWorkspaceActions.setUiLanguage.mockResolvedValueOnce({
language: 'en',
outputLanguage: null,
refresh: { runtimes: 1, sessions: 0, failed: 1 },
});
const onToast = vi.fn();
const onError = vi.fn();
const { container } = renderApp({ onToast, onError });
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
await act(async () => {
const button = container.querySelector<HTMLButtonElement>(
'[data-testid="change-language-workspace"]',
);
expect(button).not.toBeNull();
button!.click();
await Promise.resolve();
});
await flush();
expect(
container.querySelector('[data-web-shell-root]')?.getAttribute('lang'),
).toBe('en');
expect(onToast).toHaveBeenCalledWith('warning', expect.any(String));
expect(onError).not.toHaveBeenCalled();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledOnce();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('keeps explicit host UI language authoritative over the applied route locale', async () => {
testState.settings = [languageSetting('en')];
mockWorkspaceActions.setUiLanguage.mockResolvedValueOnce({
language: 'ru',
outputLanguage: null,
refresh: { runtimes: 1, sessions: 1, failed: 0 },
});
const onLanguageResolved = vi.fn();
const onLanguageChange = vi.fn();
const { container } = renderApp({
language: 'zh-CN',
onLanguageResolved,
onLanguageChange,
});
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
await act(async () => {
const button = container.querySelector<HTMLButtonElement>(
'[data-testid="change-language-workspace"]',
);
expect(button).not.toBeNull();
button!.click();
await Promise.resolve();
});
await flush();
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledWith(
'en',
'workspace',
expect.any(String),
);
expect(
container.querySelector('[data-web-shell-root]')?.getAttribute('lang'),
).toBe('zh-CN');
expect(onLanguageResolved).not.toHaveBeenCalled();
expect(onLanguageChange).not.toHaveBeenCalled();
});
it('never turns a rolled-back settings language pick into a host opinion', async () => {
// With no language prop, the resolved value is settings-derived. If the
// /language sync fails, the rollback must restore it through the
@@ -49886,7 +50238,7 @@ describe('settings-derived theme and language (#11955)', () => {
await flush();
expect(onLanguageResolved).toHaveBeenLastCalledWith('zh-CN');
mockSessionActions.sendPrompt.mockRejectedValueOnce(
mockWorkspaceActions.setUiLanguage.mockRejectedValueOnce(
new Error('daemon refused'),
);
testState.prompt = '/settings';
@@ -49939,7 +50291,7 @@ describe('settings-derived theme and language (#11955)', () => {
const onLanguageResolved = vi.fn();
renderApp({ onLanguageChange, onLanguageResolved });
await flush();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
mockWorkspaceActions.setUiLanguage.mockRejectedValueOnce(
new Error('daemon refused'),
);
@@ -49950,7 +50302,8 @@ describe('settings-derived theme and language (#11955)', () => {
});
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(onLanguageResolved).toHaveBeenLastCalledWith('zh-CN');
expect(onLanguageChange).not.toHaveBeenCalled();
});
@@ -49959,7 +50312,7 @@ describe('settings-derived theme and language (#11955)', () => {
const onLanguageResolved = vi.fn();
renderApp({ language: 'en', onLanguageResolved });
await flush();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
mockWorkspaceActions.setUiLanguage.mockRejectedValueOnce(
new Error('daemon refused'),
);
@@ -49970,7 +50323,8 @@ describe('settings-derived theme and language (#11955)', () => {
});
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
expect(mockWorkspaceActions.setUiLanguage).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(onLanguageResolved).not.toHaveBeenCalled();
});
});
+50 -72
View File
@@ -14543,6 +14543,10 @@ export function App({
});
}, [brandResolved, brandNameValue, brandLogoUri]);
const uiLanguageWorkspaceCwd =
effectiveSessionContext?.kind === 'live'
? (workspace.capabilities?.workspaceCwd ?? workspace.workspaceCwd)
: getComposerWorkspaceCwd();
const handleSettingsLanguageChange = useCallback(
(nextLanguage: WebShellLanguage, scope: 'user' | 'workspace' = 'user') => {
if (sessionWriteBlocked) return;
@@ -14553,18 +14557,12 @@ export function App({
// row on the Workspace tab there; keep this guard for a scope that was
// picked before the session context settled.
const effectiveScope = sessionIsStandalone ? 'user' : scope;
// Forward the settings tab's scope to the command so a Workspace-tab edit
// persists to workspace settings instead of always writing user scope
// (the /language command otherwise defaults to user). The command still
// switches the daemon's live locale so command descriptions re-localize —
// which a plain scoped settings write wouldn't do.
const scopeFlag =
effectiveScope === 'workspace' ? ' --project' : ' --global';
const command = `/language ui ${nextLanguage}${scopeFlag}`;
const refreshSettings = async () => {
if (!owner.current.isCurrent()) return;
await Promise.all([
sessionActions.refreshCommands(),
...(connectionRef.current.sessionId
? [sessionActions.refreshCommands()]
: []),
reloadWorkspaceSettings(),
]);
};
@@ -14586,16 +14584,28 @@ export function App({
setSelectedLanguage(nextLanguage);
onLanguageResolvedRef.current?.(nextLanguage);
}
sendPrompt(command, undefined, undefined, {
ownerRef: owner,
suppressSessionTitle: true,
})
.then(() => {
workspaceActions
.setUiLanguage(
nextLanguage === 'zh-CN' ? 'zh' : nextLanguage,
effectiveScope,
uiLanguageWorkspaceCwd,
)
.then((result) => {
if (!owner.current.isCurrent()) return;
const appliedLanguage =
languageSettingToWebShellLanguage(result.language) ??
normalizeLanguage(result.language);
if (!hostControlsLanguage && appliedLanguage !== nextLanguage) {
setSelectedLanguage(appliedLanguage);
onLanguageResolvedRef.current?.(appliedLanguage);
}
if (effectiveScope === 'user') {
// A user-scoped choice becomes the host's persisted opinion. A
// workspace choice remains settings-owned for that workspace.
handleLanguageChange(nextLanguage);
handleLanguageChange(appliedLanguage);
}
if (result.refresh.failed > 0) {
pushToast('warning', t('language.refreshFailed'));
}
return refreshSettings().catch((error: unknown) => {
if (!owner.current.isCurrent()) return;
@@ -14618,12 +14628,15 @@ export function App({
reportError,
providedLanguage,
sessionWriteBlocked,
sendPrompt,
workspaceActions,
uiLanguageWorkspaceCwd,
selectedLanguage,
sessionActions,
sessionOwnerGuard,
sessionIsStandalone,
isGoalGateBlocked,
pushToast,
t,
],
);
@@ -18673,7 +18686,7 @@ export function App({
}
if (cmd === 'language') {
const args = text.slice(match[0].length).trim();
const [subCommand, languageArg] = args.split(/\s+/);
const [subCommand] = args.split(/\s+/);
if (!args) {
store.dispatch([
{
@@ -18694,6 +18707,17 @@ export function App({
return true;
}
if (subCommand?.toLowerCase() === 'ui') {
const projectScope = /(?:^|\s)--project(?:\s|$)/.test(args);
const globalScope = /(?:^|\s)--global(?:\s|$)/.test(args);
if (projectScope && globalScope) {
pushToast('error', t('language.usage'));
return true;
}
const languageArg = args
.slice(subCommand.length)
.replace(/(?:^|\s)--project(?:\s|$)/, ' ')
.replace(/(?:^|\s)--global(?:\s|$)/, ' ')
.trim();
if (!languageArg) {
store.dispatch([
{
@@ -18719,61 +18743,15 @@ export function App({
pushToast('error', t('language.invalid'));
return true;
}
const owner = { current: sessionOwnerGuard.capture() };
const previousLanguage = selectedLanguage;
// The daemon sync is what keeps the agent answering in the
// language the chrome just switched to, so when it cannot run
// (turn in flight, or a Goal owning the session) refuse the
// command instead of switching the UI alone — the language
// picker treats the identical condition the same way.
if (commandBlocked) return blockCommand();
setSelectedLanguage(nextLanguage);
if (providedLanguage === undefined) {
onLanguageResolvedRef.current?.(nextLanguage);
}
{
const deferComposerCommit =
Boolean(
prepareSubmitRef.current || onSubmitBeforeRef.current,
) || createSessionPromiseRef.current !== null;
const clearComposerOnPromptStart =
!connectionRef.current.sessionId || deferComposerCommit;
sendPrompt(
`/language ui ${nextLanguage}`,
undefined,
undefined,
{
ownerRef: owner,
suppressSessionTitle: true,
clearComposerOnPromptStart,
commitComposerAccepted: clearComposerOnPromptStart
? commitComposerAccepted
: undefined,
},
)
.then(() => {
if (!owner.current.isCurrent()) return;
handleLanguageChange(nextLanguage);
return sessionActions
.refreshCommands()
.catch((error: unknown) => {
if (!owner.current.isCurrent()) return;
reportError(
error,
'Failed to refresh commands after /language',
);
});
})
.catch((error: unknown) => {
if (!owner.current.isCurrent()) return;
setSelectedLanguage(previousLanguage);
if (providedLanguage === undefined) {
onLanguageResolvedRef.current?.(previousLanguage);
}
reportError(error, 'Failed to sync /language command');
});
return clearComposerOnPromptStart ? false : true;
if (projectScope && !uiLanguageWorkspaceCwd) {
pushToast('info', t('session.workspaceActionUnavailable'));
return true;
}
handleSettingsLanguageChange(
nextLanguage,
projectScope ? 'workspace' : 'user',
);
return true;
}
}
@@ -19737,7 +19715,8 @@ export function App({
handleThemeChange,
handleSetMode,
setComposerMode,
handleLanguageChange,
handleSettingsLanguageChange,
uiLanguageWorkspaceCwd,
blockCommand,
createSideTask,
sideTasksAvailable,
@@ -19750,7 +19729,6 @@ export function App({
runVisibleBtw,
reconcileCatalogRename,
requireActiveSessionForLocalCommand,
providedLanguage,
selectedLanguage,
setPendingModel,
selectWelcomeModel,
@@ -9,6 +9,38 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDaemonWorkspaceActions } from './actions.js';
describe('workspace actions', () => {
it('changes UI language without a session and targets the requested workspace', async () => {
const result = {
language: 'en',
outputLanguage: null,
refresh: { runtimes: 1, sessions: 1, failed: 1 },
};
const setUserLanguage = vi.fn().mockResolvedValue(result);
const setUiLanguage = vi.fn().mockResolvedValue(result);
const workspaceByCwd = vi.fn(() => ({ setUiLanguage }));
const actions = createDaemonWorkspaceActions({
getClient: () =>
({ setUserLanguage, workspaceByCwd }) as unknown as DaemonClient,
getWorkspaceCwd: () => '/primary',
baseUrl: '',
});
await expect(actions.setUiLanguage('ru', 'user')).resolves.toEqual(result);
expect(setUserLanguage).toHaveBeenCalledWith('ru');
await expect(
actions.setUiLanguage('ru', 'user', '/secondary'),
).resolves.toEqual(result);
expect(setUserLanguage).toHaveBeenCalledTimes(2);
expect(workspaceByCwd).not.toHaveBeenCalled();
await expect(
actions.setUiLanguage('ru', 'workspace', '/secondary'),
).resolves.toEqual(result);
expect(workspaceByCwd).toHaveBeenCalledWith('/secondary');
expect(setUiLanguage).toHaveBeenCalledWith('ru');
await expect(actions.setUiLanguage('ru', 'workspace')).rejects.toThrow(
'unavailable',
);
expect(setUserLanguage).toHaveBeenCalledTimes(2);
});
afterEach(() => {
vi.useRealTimers();
});
@@ -62,6 +62,18 @@ export function createDaemonWorkspaceActions({
getClient(),
)(daemonRoutePath(url, baseUrl), init);
return {
async setUiLanguage(language, scope, workspaceCwd) {
const client = requireClient(getClient, 'Change UI language failed');
if (scope === 'workspace' && !workspaceCwd) {
throw new Error('The selected workspace is unavailable');
}
return withActionTimeout(
scope === 'workspace' && workspaceCwd
? client.workspaceByCwd(workspaceCwd).setUiLanguage(language)
: client.setUserLanguage(language),
'Change UI language timed out',
);
},
async listSessions(options) {
const client = requireClient(getClient, 'List sessions failed');
const cwd = getWorkspaceCwd();
@@ -114,6 +114,7 @@ import type {
DaemonWriteMemoryRequest,
DaemonWriteMemoryResult,
DaemonRevisionRequest,
SetUserLanguageResult,
} from '@qwen-code/sdk/daemon';
// ── Resource Hook Types (shared by workspace hooks) ────────────────
@@ -518,6 +519,11 @@ export type DaemonWorkspaceDirectoryPickerResult =
};
export interface DaemonWorkspaceActions {
setUiLanguage(
language: string,
scope: 'user' | 'workspace',
workspaceCwd?: string,
): Promise<SetUserLanguageResult>;
// Sessions
listSessions(
options?: DaemonSessionListPageOptions,
+1
View File
@@ -181,6 +181,7 @@ describe('web-shell ru locale', () => {
const t = getTranslator(language);
expect(t('language.invalid')).toContain('en, zh-CN, ru');
expect(t('language.usage')).toContain('en|zh-CN|ru');
expect(t('language.usage')).toContain('[--project|--global]');
}
});
+5 -2
View File
@@ -2770,7 +2770,9 @@ const EN: Messages = {
'language.invalid': `Invalid language. Available: ${WEB_SHELL_LANGUAGES.join(', ')}`,
'language.options': 'Available options:',
'language.set': 'Set UI language',
'language.usage': `Usage: /language ui [${WEB_SHELL_LANGUAGES.join('|')}] | output <language>`,
'language.refreshFailed':
'UI language saved, but it could not be fully applied.',
'language.usage': `Usage: /language ui [${WEB_SHELL_LANGUAGES.join('|')}] [--project|--global] | output <language>`,
'localCommand.noSession':
'No active session yet. Send your first message before using this command.',
'localCommand.diffNoWorkspace':
@@ -7421,7 +7423,8 @@ const ZH: Messages = {
'language.invalid': `语言无效。可用值:${WEB_SHELL_LANGUAGES.join(', ')}`,
'language.options': '可用选项:',
'language.set': '设置 UI 语言',
'language.usage': `用法:/language ui [${WEB_SHELL_LANGUAGES.join('|')}] | output <language>`,
'language.refreshFailed': 'UI 语言已保存,但未能完全生效。',
'language.usage': `用法:/language ui [${WEB_SHELL_LANGUAGES.join('|')}] [--project|--global] | output <language>`,
'localCommand.noSession':
'当前还没有会话。请先发送第一条消息,再使用这个命令。',
'localCommand.diffNoWorkspace': '当前还没有可用于查看变更的工作区。',