diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 60ce3a9740..173e9b0117 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -3279,6 +3279,58 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { return response; }, + async setSessionLanguage(sessionId, params, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + + const result = (await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionLanguage, + { + sessionId, + language: params.language, + syncOutputLanguage: params.syncOutputLanguage, + }, + ), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionLanguage, + ), + getTransportClosedReject(entry), + ])) as { + language: string; + outputLanguage: string | null; + refreshed: boolean; + }; + + try { + entry.events.publish({ + type: 'language_changed', + data: { + sessionId: entry.sessionId, + language: result.language, + outputLanguage: result.outputLanguage, + refreshed: result.refreshed ?? false, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch { + /* bus closed */ + } + + return { + language: result.language, + outputLanguage: result.outputLanguage ?? null, + refreshed: result.refreshed ?? false, + }; + }, + async setSessionApprovalMode(sessionId, mode, opts, context) { // #4175 Wave 4 PR 17. Forwards through `qwen/control/session/ // approval_mode` so the change lands inside the ACP child's own diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 0db96d6dc0..f3b487ed58 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -350,6 +350,22 @@ export interface HttpAcpBridge { context?: BridgeClientRequestContext, ): Promise; + /** + * Switch UI language and optionally LLM output language for a live + * session, then broadcast a `language_changed` event. When + * `syncOutputLanguage` is true the handler also refreshes every + * session's system prompt so the next LLM call uses the new language. + */ + setSessionLanguage( + sessionId: string, + params: { language: string; syncOutputLanguage: boolean }, + context?: BridgeClientRequestContext, + ): Promise<{ + language: string; + outputLanguage: string | null; + refreshed: boolean; + }>; + /** * Change the approval mode of a live session and broadcast an * `approval_mode_changed` event. `opts.persist === true` also writes diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 2d0875cce0..88bb30a7dc 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -121,6 +121,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionRecap: 'qwen/control/session/recap', sessionBtw: 'qwen/control/session/btw', sessionShellHistory: 'qwen/control/session/shell_history', + sessionLanguage: 'qwen/control/session/language', workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', // T2.8 (#4514): runtime MCP server mutation ext-methods workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index da6449f0c2..bb5dd76fdc 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -102,6 +102,13 @@ import { } from '../utils/acpModelUtils.js'; import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; import { runExitCleanup } from '../utils/cleanup.js'; +import { setLanguageAsync, getCurrentLanguage } from '../i18n/index.js'; +import { + resolveOutputLanguage, + updateOutputLanguageFile, + isAutoLanguage, + OUTPUT_LANGUAGE_AUTO, +} from '../utils/languageUtils.js'; import { ACP_PREFLIGHT_KINDS, STATUS_SCHEMA_VERSION, @@ -2516,6 +2523,87 @@ class QwenAgent implements Agent { const current = config.getApprovalMode(); return { previous, current }; } + case SERVE_CONTROL_EXT_METHODS.sessionLanguage: { + const sessionId = params['sessionId']; + const language = params['language']; + const syncOutputLanguage = params['syncOutputLanguage'] === true; + + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + if (typeof language !== 'string' || !language) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing language', + ); + } + + this.sessionOrThrow(sessionId); + + try { + await setLanguageAsync(language); + } catch (err) { + debugLogger.warn('setLanguageAsync failed:', err); + throw new RequestError( + -32603, + `Failed to switch UI language: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const resolvedLanguage = getCurrentLanguage(); + + try { + this.settings.setValue( + SettingScope.User, + 'general.language', + language, + ); + } catch (err) { + debugLogger.warn('Failed to persist UI language setting:', err); + } + + let outputLanguage: string | null = null; + let refreshed = false; + + if (syncOutputLanguage) { + const resolved = resolveOutputLanguage(language); + const settingValue = isAutoLanguage(language) + ? OUTPUT_LANGUAGE_AUTO + : resolved; + + try { + updateOutputLanguageFile(settingValue); + } catch (err) { + debugLogger.warn('Failed to write output-language.md:', err); + } + + try { + this.settings.setValue( + SettingScope.User, + 'general.outputLanguage', + settingValue, + ); + } catch (err) { + debugLogger.warn('Failed to persist output language setting:', err); + } + + const allSessions = [...this.sessions.values()]; + await Promise.allSettled( + allSessions.map(async (s) => { + const cfg = s.getConfig(); + await cfg.refreshHierarchicalMemory(); + await cfg.getGeminiClient()?.refreshSystemInstruction(); + }), + ); + refreshed = true; + outputLanguage = resolved; + } + + return { language: resolvedLanguage, outputLanguage, refreshed }; + } case SERVE_CONTROL_EXT_METHODS.sessionRecap: { // #4175 follow-up. Generate a one-sentence "where did I leave // off" summary by running `generateSessionRecap` against the diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 63f65523a6..3a71e8fda9 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -281,6 +281,15 @@ interface FakeBridgeOpts { req: SetSessionModelRequest, context?: BridgeClientRequestContext, ) => Promise; + setLanguageImpl?: ( + sessionId: string, + params: { language: string; syncOutputLanguage: boolean }, + context?: BridgeClientRequestContext, + ) => Promise<{ + language: string; + outputLanguage: string | null; + refreshed: boolean; + }>; setApprovalModeImpl?: ( sessionId: string, mode: ApprovalMode, @@ -409,6 +418,11 @@ interface FakeBridge extends HttpAcpBridge { req: SetSessionModelRequest; context?: BridgeClientRequestContext; }>; + setLanguageCalls: Array<{ + sessionId: string; + params: { language: string; syncOutputLanguage: boolean }; + context?: BridgeClientRequestContext; + }>; setApprovalModeCalls: Array<{ sessionId: string; mode: ApprovalMode; @@ -636,6 +650,17 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { tasks: [], })); const setModelImpl = opts.setModelImpl ?? (async () => ({})); + const setLanguageCalls: FakeBridge['setLanguageCalls'] = []; + const setLanguageImpl = + opts.setLanguageImpl ?? + (async ( + _sessionId: string, + params: { language: string; syncOutputLanguage: boolean }, + ) => ({ + language: params.language, + outputLanguage: params.syncOutputLanguage ? 'Chinese' : null, + refreshed: params.syncOutputLanguage, + })); const setApprovalModeCalls: FakeBridge['setApprovalModeCalls'] = []; const setApprovalModeImpl = opts.setApprovalModeImpl ?? @@ -746,6 +771,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionSupportedCommandsCalls, sessionTasksCalls, setModelCalls, + setLanguageCalls, setApprovalModeCalls, generateSessionRecapCalls, setToolEnabledCalls, @@ -901,6 +927,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { setModelCalls.push({ sessionId, req, ...(context ? { context } : {}) }); return setModelImpl(sessionId, req, context); }, + async setSessionLanguage(sessionId, params, context) { + setLanguageCalls.push({ + sessionId, + params, + ...(context ? { context } : {}), + }); + return setLanguageImpl(sessionId, params, context); + }, async setSessionApprovalMode(sessionId, mode, o, context) { setApprovalModeCalls.push({ sessionId, @@ -3132,6 +3166,100 @@ describe('createServeApp', () => { }); }); + describe('POST /session/:id/language', () => { + it('200 with language result on success', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ language: 'zh', syncOutputLanguage: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + language: 'zh', + outputLanguage: 'Chinese', + refreshed: true, + }); + expect(bridge.setLanguageCalls).toHaveLength(1); + expect(bridge.setLanguageCalls[0]).toMatchObject({ + sessionId: 'session-A', + params: { language: 'zh', syncOutputLanguage: true }, + }); + }); + + it('syncOutputLanguage defaults to false when omitted', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ language: 'en' }); + expect(res.status).toBe(200); + expect(bridge.setLanguageCalls[0]?.params.syncOutputLanguage).toBe(false); + }); + + it('passes client identity context into the bridge', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('X-Qwen-Client-Id', 'client-1') + .send({ language: 'ja' }); + expect(res.status).toBe(200); + expect(bridge.setLanguageCalls[0]?.context).toEqual({ + clientId: 'client-1', + }); + }); + + it('400 on missing or invalid language code', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const missing = await request(app) + .post('/session/session-A/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(missing.status).toBe(400); + expect(missing.body.code).toBe('invalid_language'); + expect(missing.body.allowed).toContain('zh'); + expect(missing.body.allowed).toContain('auto'); + + const unknown = await request(app) + .post('/session/session-A/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ language: 'xx-invalid' }); + expect(unknown.status).toBe(400); + expect(bridge.setLanguageCalls).toHaveLength(0); + }); + + it('400 when syncOutputLanguage is non-boolean', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ language: 'zh', syncOutputLanguage: 'yes' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_sync_flag'); + expect(bridge.setLanguageCalls).toHaveLength(0); + }); + + it('404 when bridge reports unknown session', async () => { + const bridge = fakeBridge({ + setLanguageImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/missing/language') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ language: 'zh' }); + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('missing'); + }); + }); + describe('POST /workspace/init (#4175 Wave 4 PR 17)', () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const auth = (req: request.Test): request.Test => diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 04bbbc95cf..7791e19d9a 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -46,6 +46,7 @@ import { QwenOAuthDeviceFlowProvider } from './auth/qwenDeviceFlowProvider.js'; import { createBridgeFileSystemAdapter } from './bridgeFileSystemAdapter.js'; import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { isServeDebugMode } from './debugMode.js'; +import { SUPPORTED_LANGUAGES } from '../i18n/index.js'; import { isLoopbackBind } from './loopbackBinds.js'; import { mountAcpHttp } from './acpHttp/index.js'; import { @@ -291,7 +292,7 @@ function resolveDaemonTelemetryRoute( return { route: 'POST /sessions/delete' }; } const sessionAction = path.match( - /^\/session\/([^/]+)\/(load|resume|prompt|cancel|recap|btw|model|shell|detach|approval-mode)$/, + /^\/session\/([^/]+)\/(load|resume|prompt|cancel|recap|btw|model|shell|detach|approval-mode|language)$/, ); const sessionActionId = sessionAction?.[1]; const sessionActionName = sessionAction?.[2]; @@ -2292,6 +2293,57 @@ export function createServeApp( }, ); + const LANGUAGE_CODES = [...SUPPORTED_LANGUAGES.map((l) => l.code), 'auto']; + + app.post('/session/:id/language', mutate(), async (req, res) => { + const sessionId = req.params['id']; + const body = safeBody(req); + const language = body['language']; + const syncOutputLanguage = body['syncOutputLanguage']; + + if (typeof language !== 'string' || !LANGUAGE_CODES.includes(language)) { + res.status(400).json({ + error: + '`language` is required and must be one of: ' + + LANGUAGE_CODES.join(', '), + code: 'invalid_language', + allowed: LANGUAGE_CODES, + }); + return; + } + + if ( + syncOutputLanguage !== undefined && + typeof syncOutputLanguage !== 'boolean' + ) { + res.status(400).json({ + error: '`syncOutputLanguage` must be a boolean when provided', + code: 'invalid_sync_flag', + }); + return; + } + + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + + try { + const response = await bridge.setSessionLanguage( + sessionId, + { + language, + syncOutputLanguage: syncOutputLanguage === true, + }, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(response); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /session/:id/language', + sessionId, + }); + } + }); + app.post( '/workspace/mcp/:server/restart', mutate({ strict: true }),