Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3279,6 +3279,58 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
return response;
},

async setSessionLanguage(sessionId, params, context) {
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
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(
Comment thread
chiga0 marked this conversation as resolved.
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',
Comment thread
chiga0 marked this conversation as resolved.
data: {
sessionId: entry.sessionId,
language: result.language,
Comment thread
chiga0 marked this conversation as resolved.
outputLanguage: result.outputLanguage,
refreshed: result.refreshed ?? false,
},
...(originatorClientId ? { originatorClientId } : {}),
});
Comment thread
chiga0 marked this conversation as resolved.
} catch {
/* bus closed */
}

return {
Comment thread
chiga0 marked this conversation as resolved.
language: result.language,
outputLanguage: result.outputLanguage ?? null,
refreshed: result.refreshed ?? false,
};
},
Comment thread
chiga0 marked this conversation as resolved.

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
Expand Down
16 changes: 16 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,22 @@ export interface HttpAcpBridge {
context?: BridgeClientRequestContext,
): Promise<SetSessionModelResponse>;

/**
* 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
Comment thread
chiga0 marked this conversation as resolved.
* session's system prompt so the next LLM call uses the new language.
*/
setSessionLanguage(
sessionId: string,
params: { language: string; syncOutputLanguage: boolean },
context?: BridgeClientRequestContext,
Comment thread
chiga0 marked this conversation as resolved.
): 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
Expand Down
1 change: 1 addition & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2516,6 +2523,87 @@ class QwenAgent implements Agent {
const current = config.getApprovalMode();
return { previous, current };
}
case SERVE_CONTROL_EXT_METHODS.sessionLanguage: {
Comment thread
chiga0 marked this conversation as resolved.
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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The language validation here only checks for non-empty string, but does not validate against the allowed LANGUAGE_CODES list. The HTTP route in server.ts:2303 validates against LANGUAGE_CODES.includes(language), and the approval-mode handler at line ~2500 validates against APPROVAL_MODES.includes(mode) — this handler should follow the same defense-in-depth pattern.

Direct extMethod callers (bypassing the HTTP route) can pass arbitrary strings, which setLanguageAsync will silently accept via resolveSupportedLanguage's fallback, setting currentLanguage to an unrecognized code.

Suggested change
if (typeof language !== 'string' || !language) {
const LANGUAGE_CODES = [...SUPPORTED_LANGUAGES.map((l) => l.code), 'auto'];
if (typeof language !== 'string' || !LANGUAGE_CODES.includes(language)) {
throw RequestError.invalidParams(
undefined,
`Invalid language; must be one of: ${LANGUAGE_CODES.join(', ')}`,
);

— qwen3.7-max via Qwen Code /review

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();

Comment thread
chiga0 marked this conversation as resolved.
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(
Comment thread
chiga0 marked this conversation as resolved.
allSessions.map(async (s) => {
const cfg = s.getConfig();
await cfg.refreshHierarchicalMemory();
await cfg.getGeminiClient()?.refreshSystemInstruction();
}),
);
refreshed = true;
Comment thread
chiga0 marked this conversation as resolved.
outputLanguage = resolved;
}
Comment thread
chiga0 marked this conversation as 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
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ interface FakeBridgeOpts {
req: SetSessionModelRequest,
context?: BridgeClientRequestContext,
) => Promise<SetSessionModelResponse>;
setLanguageImpl?: (
sessionId: string,
params: { language: string; syncOutputLanguage: boolean },
context?: BridgeClientRequestContext,
) => Promise<{
language: string;
outputLanguage: string | null;
refreshed: boolean;
}>;
setApprovalModeImpl?: (
sessionId: string,
mode: ApprovalMode,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -746,6 +771,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionSupportedCommandsCalls,
sessionTasksCalls,
setModelCalls,
setLanguageCalls,
setApprovalModeCalls,
generateSessionRecapCalls,
setToolEnabledCalls,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =>
Expand Down
Loading