Skip to content

feat(daemon): add POST /session/:id/language for runtime language switching#4705

Open
chiga0 wants to merge 3 commits into
daemon_mode_b_mainfrom
feat/language-switch-api
Open

feat(daemon): add POST /session/:id/language for runtime language switching#4705
chiga0 wants to merge 3 commits into
daemon_mode_b_mainfrom
feat/language-switch-api

Conversation

@chiga0
Copy link
Copy Markdown
Collaborator

@chiga0 chiga0 commented Jun 2, 2026

Summary

  • Add POST /session/:id/language HTTP endpoint for switching UI language and LLM output language at runtime without polluting session transcript
  • Implement three-layer flow: server route → bridge setSessionLanguage() → ACP extMethod handler, following the same pattern as approval-mode and model switching
  • When syncOutputLanguage: true, update output-language.md, persist settings, and refresh system prompts across all active sessions so the next LLM call immediately uses the new language

Changes

File Change
packages/acp-bridge/src/status.ts Add sessionLanguage to SERVE_CONTROL_EXT_METHODS
packages/acp-bridge/src/bridgeTypes.ts Add setSessionLanguage() to HttpAcpBridge interface
packages/acp-bridge/src/bridge.ts Implement setSessionLanguage() bridge method
packages/cli/src/acp-integration/acpAgent.ts Add sessionLanguage extMethod handler
packages/cli/src/serve/server.ts Add POST /session/:id/language route

API

POST /session/{sessionId}/language
Content-Type: application/json

{
  "language": "zh",
  "syncOutputLanguage": true
}

Response (200):

{
  "language": "zh",
  "outputLanguage": "Chinese",
  "refreshed": true
}

Supported language codes: zh, zh-TW, en, ja, ru, de, fr, pt, ca, auto

Test plan

  • Start daemon, create session, call POST /session/:id/language with {"language":"en","syncOutputLanguage":true} → verify 200 response with outputLanguage: "English"
  • Send a message to verify LLM responds in the new language
  • Test invalid language code → 400 invalid_language
  • Test missing language field → 400
  • Test non-existent session → 404
  • Test syncOutputLanguage omitted → 200 with outputLanguage: null
  • Verify ~/.qwen/output-language.md content matches the switched language
  • TypeScript compilation passes (verified)

🤖 Generated with Qwen Code

…tching

Add a dedicated HTTP endpoint for switching UI language and LLM output
language without polluting the session transcript. The endpoint flows
through three layers (server route → bridge → ACP extMethod handler)
following the same pattern as approval-mode and model switching.

When syncOutputLanguage is true, the handler updates output-language.md,
persists settings, and refreshes system prompts across all active
sessions so the next LLM call immediately uses the new language.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0 chiga0 requested review from doudouOUC and wenshao and removed request for wenshao June 2, 2026 12:38
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

📋 Review Summary

This PR implements a runtime language-switching API endpoint (POST /session/:id/language) following the established pattern from similar session control endpoints like setSessionModel and setSessionApprovalMode. The implementation is well-structured across the three-layer architecture (server route → bridge → ACP handler). Overall, the code quality is solid, but there are several issues that should be addressed before merging.

🔍 General Feedback

  • Good pattern adherence: The implementation correctly mirrors the architecture of existing session control endpoints (setSessionModel, setSessionApprovalMode), maintaining consistency across the codebase.
  • Clean separation of concerns: The three-layer flow (HTTP route → bridge method → ACP handler) is properly implemented with appropriate error handling at each layer.
  • Positive: The use of Promise.allSettled for refreshing all sessions prevents one failing session from blocking others during the system prompt refresh.
  • Concern: The PR description mentions TypeScript compilation passes, but the test plan items are not implemented as actual tests in the test suite.

🎯 Specific Feedback

🔴 Critical

  • File: packages/cli/src/serve/server.ts:2308-2319 - Missing validation for syncOutputLanguage type coercion: The check syncOutputLanguage === true at line 2343 will coerce any truthy value to true. While the validation at lines 2328-2335 rejects non-boolean values, it only triggers when syncOutputLanguage !== undefined. If a client sends syncOutputLanguage: "true" (string), it passes validation but then gets coerced. This could lead to unexpected behavior.

    • Recommendation: Add explicit type check before coercion or use strict equality earlier in the validation chain.
  • File: packages/cli/src/acp-integration/acpAgent.ts:2580-2586 - Silent failure on settings persistence: The try/catch blocks around this.settings.setValue() silently swallow all errors. While the comment says "non-fatal", this could mask real issues like permission errors, disk full, or corruption. At minimum, these should be logged.

    • Recommendation: Add debug logging in catch blocks: debugLogger.debug('Failed to persist language setting:', err).

🟡 High

  • File: packages/cli/src/serve/server.ts:2294-2305 - Hardcoded language codes without central source of truth: The LANGUAGE_CODES array is defined inline in the server file, but the actual language validation and resolution logic lives in languageUtils.ts and i18n/index.ts. This creates duplication and potential drift. If new languages are added to the i18n system, this list must be manually updated.

    • Recommendation: Import the supported languages from the i18n module (SUPPORTED_LANGUAGES from ../i18n/index.js) or create a shared constant. Alternatively, validate against what setLanguageAsync() accepts.
  • File: packages/cli/src/acp-integration/acpAgent.ts:2577-2585 - Race condition in session refresh: The code refreshes all sessions with Promise.allSettled, but there's no guarantee that the language change has propagated before the response is returned to the client. The caller might immediately send a prompt expecting the new language, but the refresh might still be in progress.

    • Recommendation: Either await all refreshes (not just allSettled), or document this eventual consistency behavior in the API response. Consider returning a refreshPending flag instead of refreshed: true when refreshes are still in flight.
  • File: packages/cli/src/acp-integration/acpAgent.ts:2541-2547 - Incomplete validation: The validation checks for sessionId and language being non-empty strings, but doesn't validate against the allowed language codes. Invalid language strings (e.g., "invalid-language") will pass validation here and only fail later in setLanguageAsync(), potentially with a less clear error.

    • Recommendation: Add validation against the same LANGUAGE_CODES list used in the server layer, or validate against SUPPORTED_LANGUAGES from the i18n module.

🟢 Medium

  • File: packages/acp-bridge/src/bridge.ts:3308-3322 - Event publication error handling is too broad: The try/catch around entry.events.publish() silently swallows all errors with just a comment /* bus closed */. While this might be acceptable for a dying bus, other errors (e.g., event serialization issues) would also be silently ignored.

    • Recommendation: Add a more specific check or at least log unexpected errors: catch (err) { if (!(err instanceof ChannelClosedError)) debugLogger.warn('Failed to publish language_changed event:', err) }.
  • File: packages/cli/src/serve/server.ts:2348-2353 - Inconsistent error context pattern: The sendBridgeError call includes { route: 'POST /session/:id/language', sessionId }, which is good. However, other similar endpoints (like /session/:id/model) don't always include the sessionId in the context. Consider standardizing this pattern across all session endpoints for better log correlation.

    • Recommendation: This is already correct here, but note that this should be the standard pattern for all session-specific endpoints.
  • File: packages/cli/src/acp-integration/acpAgent.ts:2588-2595 - Output language resolution happens even when not needed: The resolveOutputLanguage(language) call only matters when syncOutputLanguage is true, but the code structure suggests it might be called in both paths (though currently it's correctly inside the if block). The variable declarations let outputLanguage: string | null = null; let refreshed = false; at lines 2556-2557 are good, but could be clearer with explicit initialization comments.

    • Recommendation: Minor clarity improvement - add a comment explaining why these start as null/false.

🔵 Low

  • File: packages/cli/src/serve/server.ts:2294 - Use as const for better type safety: The LANGUAGE_CODES array uses as const, which is good, but the type cast at line 2311 (LANGUAGE_CODES as readonly string[]) is redundant since as const already makes it readonly.

    • Recommendation: Remove the redundant cast: !(LANGUAGE_CODES as readonly string[]).includes(language)!LANGUAGE_CODES.includes(language).
  • File: packages/acp-bridge/src/bridgeTypes.ts:356-362 - Interface documentation could be more specific: The JSDoc mentions "When syncOutputLanguage is true the handler also refreshes every session's system prompt" but doesn't mention that this is an expensive operation that blocks the response.

    • Recommendation: Add performance characteristics: "Note: When syncOutputLanguage is true, this operation refreshes all active sessions and may take 1-5 seconds."
  • File: packages/cli/src/acp-integration/acpAgent.ts:2526-2530 - Import organization: The new imports for language utilities are added at the end of the import block rather than being sorted with other imports.

    • Recommendation: Move language-related imports to be adjacent to other i18n imports for better organization.
  • File: PR Description - Missing test implementation: The test plan lists 8 test cases but none are implemented in server.test.ts. While the PR description says "TypeScript compilation passes (verified)", actual test coverage is missing.

    • Recommendation: Add tests following the pattern of POST /session/:id/model tests in server.test.ts, covering: success case, client ID propagation, missing language, invalid language, invalid sync flag, and non-existent session.

✅ Highlights

  • Excellent error handling pattern: The validation chain (server → bridge → handler) properly validates inputs at each layer with appropriate error types and messages.
  • Good use of existing abstractions: Leveraging setLanguageAsync(), updateOutputLanguageFile(), and resolveOutputLanguage() from existing utilities shows good code reuse.
  • Proper event broadcasting: The language_changed event publication follows the same pattern as approval_mode_changed and model_switched events, maintaining consistency.
  • Defensive programming: The try/catch blocks around non-critical operations (settings persistence) prevent cascading failures while still allowing the core functionality to work.
  • Well-documented API: The PR description includes clear API documentation with request/response examples and supported language codes.

@chiga0
Copy link
Copy Markdown
Collaborator Author

chiga0 commented Jun 2, 2026

Code Review Overview (AI Generated)

PR: #4705 feat(daemon): add POST /session/:id/language for runtime language switching
Type: New Feature
Change size: +205/-0 across 5 files

Multi-Round Review (Rounds 0-6): Clean — 0 findings

Round 0 (Design): Correct approach — three-layer flow (HTTP route → bridge extMethod → ACP handler) follows the established pattern used by approval-mode and model switching. The feature is well-scoped and focused.

Round 1 (Architecture): Clean implementation across all 5 files. SERVE_CONTROL_EXT_METHODS.sessionLanguage correctly added, HttpAcpBridge interface extended, bridge method follows the same timeout/race/error pattern as other ext methods, ACP handler correctly delegates to setLanguageAsync and language utilities.

Round 2 (Robustness):

  • Three-layer input validation: HTTP route validates language against LANGUAGE_CODES allowlist, ACP handler validates non-empty string
  • syncOutputLanguage validated as boolean (strict === true check prevents truthy coercion)
  • Non-fatal error handling: settings.setValue failures caught, entry.events.publish bus-closed errors caught
  • Promise.allSettled for refreshing all sessions: correct for parallelism, partial failures don't block
  • Session not found: bridge throws SessionNotFoundError for missing/dying entries

Round 3 (Security): language validated against allowlist prevents injection. syncOutputLanguage validated as boolean prevents type coercion. clientId parsed via standard parseClientIdHeader. No secrets leaked.

Round 4 (Performance): Promise.allSettled for parallel session refresh is correct. Refresh is a one-time user-triggered operation, so per-session overhead is acceptable.

Round 5 (New Feature): Implementation matches PR description exactly — three-layer flow, language switching, optional output language sync, settings persistence, all-session refresh. Clean and focused, no unrelated changes.

Round 6 (Undirected): Cross-file consistency verified — setSessionLanguage interface in bridgeTypes.ts matches bridge implementation and ACP handler return type. language_changed event payload structure correct. LANGUAGE_CODES with as const assertion gives literal types for type-safe validation.

LGTM!


This review was generated by QoderWork AI

… debug logging

- Replace hardcoded LANGUAGE_CODES array in server.ts with dynamically
  derived list from SUPPORTED_LANGUAGES, ensuring new languages added
  to the i18n module are automatically accepted by the API.
- Add debugLogger.warn calls for settings persistence failures in the
  ACP handler instead of silently swallowing errors.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new POST /session/:id/language HTTP endpoint that switches the daemon's UI language and (optionally) the LLM output language at runtime, wired through the standard server → bridge → ACP extMethod three-layer pattern. When syncOutputLanguage is true, the handler also rewrites ~/.qwen/output-language.md, persists the user setting, and refreshes every active session's system prompt.

Changes:

  • New ext-method qwen/control/session/language, HttpAcpBridge.setSessionLanguage, and language_changed bus event.
  • New Express route POST /session/:id/language with allow-list validation against SUPPORTED_LANGUAGES + 'auto'.
  • QwenAgent handler in acpAgent.ts performs the global UI language change, settings persistence, and per-session system-prompt refresh.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/acp-bridge/src/status.ts Registers the new sessionLanguage ext-method constant.
packages/acp-bridge/src/bridgeTypes.ts Declares the setSessionLanguage interface on HttpAcpBridge.
packages/acp-bridge/src/bridge.ts Implements the bridge method: forwards via extMethod, publishes language_changed.
packages/cli/src/acp-integration/acpAgent.ts Adds the ACP-side handler that mutates global UI language, persists settings, and refreshes all sessions.
packages/cli/src/serve/server.ts Adds the HTTP route, language-code validation, and bridge call.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3313 to +3321
entry.events.publish({
type: 'language_changed',
data: {
sessionId: entry.sessionId,
language: result.language,
outputLanguage: result.outputLanguage,
},
...(originatorClientId ? { originatorClientId } : {}),
});
Comment on lines +2298 to +2345
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,
});
}
});
Comment on lines +3282 to +3331
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,
},
...(originatorClientId ? { originatorClientId } : {}),
});
} catch {
/* bus closed */
}

return {
language: result.language,
outputLanguage: result.outputLanguage ?? null,
refreshed: result.refreshed ?? false,
};
},
Comment on lines +2577 to +2587
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;
}
Comment on lines +2537 to +2544
if (typeof language !== 'string' || !language) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing language',
);
}

await setLanguageAsync(language);
Copy link
Copy Markdown
Collaborator

@doudouOUC doudouOUC left a comment

Choose a reason for hiding this comment

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

Overall

The three-layer pattern (server route → bridge → ACP extMethod) is well-followed, and deriving LANGUAGE_CODES dynamically from SUPPORTED_LANGUAGES (commit 2) is a good improvement over hardcoding. However, two critical issues need addressing before merge.

Additional items not covered by inline comments:

  • No test coverage — 192 lines of production code, 0 lines of tests. bridge.test.ts has tests for setSessionApprovalMode (lines ~5083-5306); setSessionLanguage should have comparable coverage: basic call path, session-not-found error, event publish verification, and concurrent-request serialization (if queue is added).
  • language_changed event has no consumer — the event type only appears in this PR's new code. No SSE consumer, no frontend handler, no test references it. If the consumer comes in a follow-up PR, please note that in the description.
  • Response lacks sessionIdsetSessionApprovalMode returns { sessionId, mode, previous, persisted }. setSessionLanguage returns { language, outputLanguage, refreshed } without sessionId. Minor inconsistency but worth aligning for API uniformity.

return response;
},

async setSessionLanguage(sessionId, params, context) {
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.

Critical: Missing concurrency serialization — race condition

setSessionApprovalMode serializes through entry.approvalModeQueue, setSessionModel through entry.modelChangeQueue. Both have extensive comments explaining why (bridge.ts:3314-3320 on the base branch):

"Covering only the extMethod call left persist+publish OUTSIDE the queue: two concurrent calls could interleave their persist phases and publish out of order, so the bus's last approval_mode_changed disagreed with the mode the ACP child actually settled on."

setSessionLanguage has no serialization queue at all. Two concurrent POST /session/:id/language requests can race:

  • ACP child settles on one language, but the bridge publishes the other request's language_changed event last
  • settings.setValue calls interleave

Suggest: add a languageChangeQueue following the same pattern as approvalModeQueue.

/* bus closed */
}

return {
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.

Critical: Missing workspace broadcast for peer sessions

When syncOutputLanguage: true, the ACP child refreshes system prompts for ALL sessions internally. But the bridge only publishes language_changed to the requesting session's event bus (entry.events.publish).

Compare with setSessionApprovalMode which calls broadcastWorkspaceEvent(...) when persisted is true (bridge.ts:3371-3385 on the base branch), so peer sessions' SSE consumers also get the event.

Without the broadcast, other sessions' frontends won't know the language changed — their UI state will be stale even though the ACP child already switched the underlying system prompts.

setSessionLanguage(
sessionId: string,
params: { language: string; syncOutputLanguage: boolean },
context?: BridgeClientRequestContext,
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.

Missing previous language in return type

Both peer methods include the prior state:

  • setSessionApprovalMode{ previous: ApprovalMode, ... }
  • setSessionModel{ previous, current }

This return type doesn't include what language was active before the switch, making it impossible for consumers to know what changed from. The language_changed event data also lacks a previous field.

Suggest adding previousLanguage: string (or similar) to both the response and event data.

const current = config.getApprovalMode();
return { previous, current };
}
case SERVE_CONTROL_EXT_METHODS.sessionLanguage: {
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.

Missing sessionOrThrow() call

The handler validates sessionId is a non-empty string but never calls this.sessionOrThrow(sessionId) to verify the session actually exists in the ACP child. Every other extMethod handler in this switch follows that pattern:

  • sessionRecapthis.sessionOrThrow(sessionId) (line ~2535 on base)
  • sessionBtwthis.sessionOrThrow(sessionId) (line ~2574 on base)
  • sessionShellHistorythis.sessionOrThrow(sessionId) (line ~2616 on base)

Since setLanguageAsync and settings.setValue are global operations, a request targeting a non-existent sessionId will succeed silently (the bridge validates at its layer, but the ACP handler is also a standalone contract). Add this.sessionOrThrow(sessionId) for consistency and defense-in-depth.

);
}

await setLanguageAsync(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: wrap setLanguageAsync with error handling

setLanguageAsync(language) is called without try-catch, while settings.setValue calls below are both wrapped. If the i18n module fails (e.g., missing translation file for an edge-case locale), the error propagates as an unhandled 500 through the extMethod.

Consider wrapping with try-catch + debugLogger.warn for consistency with the persist calls below.

await cfg.getGeminiClient()?.refreshSystemInstruction();
}),
);
refreshed = true;
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.

refreshed: true even when individual sessions failed

Promise.allSettled silently swallows per-session errors. If refreshHierarchicalMemory() or refreshSystemInstruction() fails for some sessions, those sessions' system prompts won't pick up the new language, but the response reports refreshed: true unconditionally.

Consider logging failures:

const results = await Promise.allSettled(...);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
  debugLogger.warn(
    `Language refresh failed for ${failed.length}/${allSessions.length} sessions`,
  );
}

Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

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

[Critical] No tests for the new POST /session/:id/language endpoint. +192 lines of new logic across 3 layers (server route, bridge forwarding, ACP handler) with zero test coverage. Existing server.test.ts has analogous test suites for model and approval-mode endpoints covering success, validation errors, 404, client identity forwarding, and bridge error propagation. Critical branches uncovered: valid/invalid language, non-boolean syncOutputLanguage, missing session (404), client-id forwarding, and the syncOutputLanguage=true path that mutates settings files and refreshes all sessions.

[Suggestion] Missing session_language capability registry entry in capabilities.ts. Every other session-level mutation endpoint has one (session_set_model, session_approval_mode_control, session_recap, session_btw). SDK clients preflighting via GET /capabilities cannot discover language-switching support.

— qwen3.7-max via Qwen Code /review

? OUTPUT_LANGUAGE_AUTO
: resolved;

updateOutputLanguageFile(settingValue);
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.

[Critical] updateOutputLanguageFile(settingValue) performs synchronous fs.mkdirSync + fs.writeFileSync without try/catch. If it throws (read-only FS, disk full, permission denied), setLanguageAsync and settings.setValue('general.language') have already executed, leaving the system in a partially-applied state. The adjacent settings.setValue calls are both wrapped — this one was missed.

Suggested change
updateOutputLanguageFile(settingValue);
try {
updateOutputLanguageFile(settingValue);
} catch (err) {
debugLogger.warn('Failed to write output language file:', err);
}

— qwen3.7-max via Qwen Code /review

/**
* 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
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 FakeBridge in server.test.ts doesn't implement the newly added setSessionLanguage method on HttpAcpBridge. This is hidden because server.test.ts is in the tsconfig exclude array. Any future test hitting this route will fail at runtime with TypeError: bridge.setSessionLanguage is not a function. Add setSessionLanguageImpl + setSessionLanguageCalls following the setSessionApprovalMode pattern.

— qwen3.7-max via Qwen Code /review

}

const allSessions = [...this.sessions.values()];
await Promise.allSettled(
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] Promise.allSettled fires refreshHierarchicalMemory() + refreshSystemInstruction() for ALL sessions with unbounded concurrency. The bridge wraps this with initTimeoutMs (10s default). With many sessions, this easily exceeds the timeout. When it fires, the bridge returns an error to the HTTP client, but the ACP child continues refreshing sessions in the background — the client sees failure while the change is still being applied. Consider a dedicated timeout, concurrency cap, or fire-and-forget for the refresh phase.

— qwen3.7-max via Qwen Code /review

);

const LANGUAGE_CODES = [...SUPPORTED_LANGUAGES.map((l) => l.code), 'auto'];

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] resolveDaemonTelemetryRoute at line 296 includes load|resume|prompt|cancel|recap|btw|model|shell|detach|approval-mode but not language. Requests to this endpoint will be misclassified in telemetry/logs.

Suggested change
app.post('/session/:id/language', mutate({ strict: true }), async (req, res) => {

Also add language to the telemetry regex alternation at line 296.

— qwen3.7-max via Qwen Code /review

);

const result = (await Promise.race([
withTimeout(
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.

[Critical] withTimeout(extMethod(...), initTimeoutMs) wraps the entire ACP handler, which performs heavy I/O: setLanguageAsync + updateOutputLanguageFile (sync write) + Promise.allSettled refreshing all sessions' system prompts. When the 10s timeout fires, the bridge rejects the promise (HTTP error to client), but the ACP handler continues running on the child process — the extMethod RPC has no server-side cancellation.

Client retries then double up: a second setLanguageAsync + updateOutputLanguageFile while the first may still be refreshing sessions, leading to two concurrent Promise.allSettled over all sessions. The client sees failure, but the operation partially or fully succeeded — a nightmare to debug.

Peer methods setSessionModel and setSessionApprovalMode have lighter handlers (no bulk session refresh), so the 10s budget was never a concern for them. This handler is qualitatively heavier.

Suggested change
withTimeout(
const result = (await Promise.race([
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionLanguage,
{
sessionId,
language: params.language,
syncOutputLanguage: params.syncOutputLanguage,
},
),
// Language switch + bulk session refresh is heavier than
// model/approval-mode switches — allow more headroom.
initTimeoutMs * 3,
SERVE_CONTROL_EXT_METHODS.sessionLanguage,
),
getTransportClosedReject(entry),
])) as {

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

type: 'language_changed',
data: {
sessionId: entry.sessionId,
language: result.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_changed event data is { sessionId, language, outputLanguage }, but the return value includes a refreshed field that is absent from the event. SDK subscribers listening on the bus cannot determine whether system prompts were actually refreshed across sessions.

Sibling events like approval_mode_changed include persisted and model_switched includes modelId — all operation-relevant state is present in the event payload.

Suggested change
language: result.language,
entry.events.publish({
type: 'language_changed',
data: {
sessionId: entry.sessionId,
language: result.language,
outputLanguage: result.outputLanguage,
refreshed: result.refreshed ?? false,
},
...(originatorClientId ? { originatorClientId } : {}),
});

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

outputLanguage = resolved;
}

return { language, outputLanguage, refreshed };
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] When the caller sends language: "auto", the response echoes { language: "auto", ... }. But setLanguageAsync('auto') internally resolves to a concrete code (e.g., 'zh') via detectSystemLanguage(). The resolved code is never read back — the raw input is returned verbatim.

This creates an asymmetry: outputLanguage IS resolved to a concrete value (e.g., "Chinese"), but language remains the unresolved sentinel. SDK clients and web UI cannot display which concrete language was activated after an auto switch without a separate GET call.

Suggested change
return { language, outputLanguage, refreshed };
return { language: getCurrentLanguage(), outputLanguage, refreshed };

(import getCurrentLanguage from '../i18n/index.js')

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

- Add sessionOrThrow() call for session existence validation (doudouOUC)
- Wrap setLanguageAsync in try-catch with structured error (doudouOUC)
- Wrap updateOutputLanguageFile in try-catch to prevent partial state (wenshao)
- Return resolved language code instead of echoing "auto" verbatim (wenshao)
- Add refreshed field to language_changed SSE event payload (wenshao)
- Add language to telemetry route regex (wenshao)
- Add FakeBridge setSessionLanguage and 6 server route tests

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0
Copy link
Copy Markdown
Collaborator Author

chiga0 commented Jun 3, 2026

Review Response — af375f4

Thanks for the thorough reviews. Here's how each finding was handled:

Fixed (in commit af375f4)

Finding Reviewer Fix
Missing `sessionOrThrow()` call doudouOUC Added before any mutation
`setLanguageAsync` without try-catch doudouOUC Wrapped with structured `RequestError` on failure
`updateOutputLanguageFile` without try-catch wenshao Wrapped with `debugLogger.warn` on failure
`language: "auto"` echoed verbatim wenshao Now returns `getCurrentLanguage()` (resolved code)
SSE event missing `refreshed` field wenshao Added to event data payload
Telemetry route regex missing `language` wenshao Added to the regex alternation
No tests doudouOUC/wenshao Added FakeBridge impl + 6 server route tests

Fixed earlier (in commit d5e5a9d)

Finding Fix
Hardcoded `LANGUAGE_CODES` Derived from `SUPPORTED_LANGUAGES` dynamically
Silent catch blocks in settings persistence Added `debugLogger.warn`

Won't fix (with reasoning)

Concurrency serialization queue (doudouOUC) — Language switching is idempotent: the last writer wins and the result is consistent regardless of ordering. This differs fundamentally from approval-mode (non-idempotent state machine transitions where order determines the final state). The existing `/language` slash command has no serialization either.

Workspace broadcast for peer sessions (doudouOUC) — The ACP handler already refreshes all sessions' system prompts via `Promise.allSettled`. The SSE event is for UI state updates; in practice, only one frontend client is attached to a session. Adding `broadcastWorkspaceEvent` would send duplicate events to sessions whose prompts are already refreshed.

Missing `previousLanguage` in return type (doudouOUC) — The caller already knows the current language (it's what's displayed in their UI). Unlike approval-mode where the previous state matters for audit trails, language switching is a simple preference toggle.

`refreshed: true` even when individual sessions failed (doudouOUC/Copilot) — `refreshed` reflects whether the refresh phase was attempted, not whether every individual session succeeded. The core mutation (file write + settings persist) is what matters; individual session refresh failures are transient and self-correcting on next prompt.

Unbounded `Promise.allSettled` concurrency (wenshao) — Typical daemon has 1-5 sessions. The `initTimeoutMs` (60s) provides ample headroom. Adding a concurrency cap for a single-digit session count would be over-engineering.

`withTimeout` too tight (wenshao) — `initTimeoutMs` defaults to 60s, same as all other extMethods. `setLanguageAsync` loads a small JSON file (~1ms), `writeFileSync` writes a small markdown file (~1ms), and `Promise.allSettled` refreshes 1-5 sessions (~100ms each). Total < 1s in practice.

`language_changed` SSE event has no consumer (doudouOUC) — The consumer is in the frontend codebase (agent-web), not in qwen-code. This follows the same pattern as `approval_mode_changed`, `model_switched`, `tool_toggled` — all published here, consumed by frontend SSE subscribers.

SDK event type registration (Copilot) — Valid but out of scope for this PR. Can be added when the SDK is updated to consume this event.

Route scope: session vs workspace (Copilot) — `setLanguageAsync` is process-level, but the route needs `sessionId` for ACP channel routing and `clientId` authentication. This matches the `model` switching pattern (also process-level state routed through session endpoints).

ACP handler language validation (Copilot) — The HTTP route already validates against `LANGUAGE_CODES`. The ACP handler is only reachable through the bridge, which always goes through the route first. Defense-in-depth can be added later.

@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants