Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.
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,
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
SERVE_CONTROL_EXT_METHODS.sessionLanguage,
),
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
getTransportClosedReject(entry),
])) as {
language: string;
outputLanguage: string | null;
refreshed: boolean;
};

try {
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
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
99 changes: 99 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,17 @@ import {
} from '../utils/acpModelUtils.js';
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
import { runExitCleanup } from '../utils/cleanup.js';
import {
setLanguageAsync,
getCurrentLanguage,
SUPPORTED_LANGUAGES,
} 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 +2527,94 @@ 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',
);
}
const allowedLanguages = [
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
...SUPPORTED_LANGUAGES.map((l) => l.code),
'auto',
];
if (
typeof language !== 'string' ||
!allowedLanguages.includes(language)
) {
throw RequestError.invalidParams(
undefined,
`Invalid language; must be one of: ${allowedLanguages.join(', ')}`,
);
}

this.sessionOrThrow(sessionId);

try {
await setLanguageAsync(language);
Comment thread
chiga0 marked this conversation as resolved.
} 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);
Comment thread
chiga0 marked this conversation as resolved.
} catch (err) {
debugLogger.warn('Failed to write output-language.md:', err);
Comment thread
chiga0 marked this conversation as resolved.
}

try {
Comment thread
chiga0 marked this conversation as resolved.
Outdated
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.
Outdated
allSessions.map(async (s) => {
const cfg = s.getConfig();
await cfg.refreshHierarchicalMemory();
await cfg.getGeminiClient()?.refreshSystemInstruction();
}),
Comment thread
chiga0 marked this conversation as resolved.
Outdated
Comment thread
chiga0 marked this conversation as resolved.
Outdated
Comment thread
chiga0 marked this conversation as resolved.
Outdated
);
refreshed = true;
Comment thread
chiga0 marked this conversation as resolved.
Outdated
Comment thread
chiga0 marked this conversation as resolved.
Outdated
Comment thread
chiga0 marked this conversation as resolved.
Outdated
Comment thread
chiga0 marked this conversation as resolved.
Outdated
outputLanguage = resolved;
}
Comment thread
chiga0 marked this conversation as resolved.
Outdated

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', () => {
Comment thread
chiga0 marked this conversation as resolved.
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')
Comment thread
chiga0 marked this conversation as resolved.
.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