diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 1ae5f2420..5ab144472 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -476,7 +476,7 @@ describe('languageCommand', () => { 'output Chinese', ); - // Verify setting was saved (rule file is updated on restart) + // Verify the setting was persisted. expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( expect.anything(), // SettingScope.User 'general.outputLanguage', @@ -489,20 +489,80 @@ describe('languageCommand', () => { }); }); - it('should include restart notice in success message', async () => { + it('should apply the new output language to the running session without requiring a restart', async () => { if (!languageCommand.action) { throw new Error('The language command must have an action.'); } + const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined); + const refreshSystemInstruction = vi.fn().mockResolvedValue(undefined); + const getGeminiClient = vi + .fn() + .mockReturnValue({ refreshSystemInstruction }); + ( + mockContext.services as unknown as { + config: Record; + } + ).config = { + getModel: vi.fn().mockReturnValue('test-model'), + refreshHierarchicalMemory, + getGeminiClient, + }; + const result = await languageCommand.action( mockContext, 'output Japanese', ); + expect(refreshHierarchicalMemory).toHaveBeenCalledTimes(1); + expect(getGeminiClient).toHaveBeenCalledTimes(1); + expect(refreshSystemInstruction).toHaveBeenCalledTimes(1); + // Memory MUST be refreshed before the system instruction is rebuilt; + // otherwise the new instruction would be built from stale userMemory + // and the language switch would silently fail to take effect. + const memoryOrder = refreshHierarchicalMemory.mock.invocationCallOrder[0]; + const instructionOrder = + refreshSystemInstruction.mock.invocationCallOrder[0]; + expect(memoryOrder).toBeLessThan(instructionOrder); + // The success message no longer asks the user to restart. expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('restart'), + content: expect.not.stringContaining('restart'), + }); + }); + + it('should still report success when applying to the running session fails', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const refreshHierarchicalMemory = vi + .fn() + .mockRejectedValue(new Error('boom')); + ( + mockContext.services as unknown as { + config: Record; + } + ).config = { + getModel: vi.fn().mockReturnValue('test-model'), + refreshHierarchicalMemory, + // No getGeminiClient — refreshSystemInstruction must not be reached. + }; + + const result = await languageCommand.action(mockContext, 'output Korean'); + + // The setting was still persisted; the user-facing message reports + // success and does not surface the in-session refresh failure. + expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( + expect.anything(), + 'general.outputLanguage', + 'Korean', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('LLM output language set to'), }); }); @@ -536,8 +596,7 @@ describe('languageCommand', () => { ); }); - it('should save setting without immediate rule file update', async () => { - // Even though rule file updates happen on restart, the setting should still be saved + it('should save the setting and report success on a valid argument', async () => { if (!languageCommand.action) { throw new Error('The language command must have an action.'); } @@ -868,7 +927,7 @@ describe('languageCommand', () => { const result = await outputSubcommand.action(mockContext, 'French'); - // Verify setting was saved (rule file is updated on restart) + // Verify the setting was persisted. expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( expect.anything(), 'general.outputLanguage', diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index c5675b2bf..cfdbc7e10 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -128,6 +128,11 @@ async function setUiLanguage( /** * Handles the /language output command, updating both the setting and the rule file. * 'auto' is preserved in settings but resolved to the detected language for the rule file. + * + * After persisting the change, hierarchical memory is reloaded so `output-language.md` + * flows back into `userMemory`, and the live chat's system instruction is rebuilt + * in place. The new language therefore takes effect on the next turn without + * restarting the session and without losing conversation history. */ async function setOutputLanguage( context: CommandContext, @@ -155,6 +160,22 @@ async function setOutputLanguage( } } + // Apply the new rule to the running session: refresh hierarchical memory + // so output-language.md is re-read into userMemory, then rebuild and + // re-bind the system instruction on the live chat. + const config = context.services.config; + if (config) { + try { + await config.refreshHierarchicalMemory(); + await config.getGeminiClient().refreshSystemInstruction(); + } catch (error) { + debugLogger.warn( + 'Failed to apply output language to running session:', + error, + ); + } + } + // Format display message const displayLang = isAuto ? `${t('Auto (detect from system)')} → ${resolved}` @@ -163,11 +184,7 @@ async function setOutputLanguage( return { type: 'message', messageType: 'info', - content: [ - t('LLM output language set to {{lang}}', { lang: displayLang }), - '', - t('Please restart the application for the changes to take effect.'), - ].join('\n'), + content: t('LLM output language set to {{lang}}', { lang: displayLang }), }; } catch (error) { return { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 1f14a9532..2ec683ac6 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -448,6 +448,33 @@ export class GeminiClient { ); } + /** + * Rebuilds the main-session system instruction from the current + * `userMemory` / model / prompt overrides and re-binds it to the live chat. + * + * Use this after mutating inputs that feed into the system instruction + * (e.g. user memory refreshed from `output-language.md`) so the change + * takes effect on the next turn without restarting the session. No-op if + * no chat has been started yet. + */ + async refreshSystemInstruction(): Promise { + if (!this.chat) { + return; + } + const toolRegistry = this.config.getToolRegistry(); + await toolRegistry.warmAll(); + const deferredSummary = toolRegistry.getDeferredToolSummary(); + const toolSearchAvailable = !!toolRegistry.getTool(ToolNames.TOOL_SEARCH); + const deferredTools = toolSearchAvailable + ? deferredSummary.filter( + (t) => !toolRegistry.isDeferredToolRevealed(t.name), + ) + : undefined; + this.chat.setSystemInstruction( + this.getMainSessionSystemInstruction(deferredTools), + ); + } + async startChat(extraHistory?: Content[]): Promise { this.forceFullIdeContext = true; // Clear stale cache params on session reset to prevent cross-session leakage