From b01bd53de8d8c6e9fb20bd8e812f50de6597c0b6 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 19:41:03 +0800 Subject: [PATCH 01/11] feat(core): remind the model of the active output style every turn Every non-default output style now sends its turn reminder as a on each user and cron turn, next to the date, plan-mode and arena reminders. The headless-Learning gate moves into a shared resolveEffectiveOutputStyle() so the prompt and the reminder can never disagree about which style is active. Reminder text is escaped before it is wrapped, so a file-supplied turnReminder cannot close the block. Claude-Session: https://claude.ai/code/session_01Gk1gryVzWuW58zkBtoBvnM --- packages/core/src/core/client.test.ts | 135 ++++++++++++++++++- packages/core/src/core/client.ts | 20 +++ packages/core/src/core/output-styles.test.ts | 29 ++++ packages/core/src/core/output-styles.ts | 24 ++++ packages/core/src/core/prompts.ts | 14 +- 5 files changed, 215 insertions(+), 7 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 024a11cf242..7557de2363f 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -21,7 +21,12 @@ process.env.TZ = 'UTC'; import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { Content, GenerateContentResponse, Part } from '@google/genai'; +import type { + Content, + GenerateContentResponse, + Part, + PartListUnion, +} from '@google/genai'; import { GeminiClient, SendMessageType, type SteerInput } from './client.js'; import { MESSAGE_DISPLAY_DEBOUNCE_MS } from './message-display-buffer.js'; import { getRecentGitStatus } from '../utils/gitUtils.js'; @@ -8660,6 +8665,134 @@ hello ); }); + describe('output style turn reminder', () => { + const CONCISE_REMINDER = + '\nConcise output style is active. Be concise: answer first, cut the narration, keep only what the user needs.\n'; + + async function runTurn( + request: PartListUnion, + options?: { type: SendMessageType }, + ): Promise { + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'ok' }; + })(), + ); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + } as unknown as GeminiChat; + const stream = client.sendMessageStream( + request, + new AbortController().signal, + 'prompt-id-output-style', + options, + ); + for await (const _ of stream) { + // consume stream + } + return mockTurnRunFn.mock.lastCall?.[1] as unknown[]; + } + + function reminderParts(request: unknown[]): string[] { + return request.filter( + (part): part is string => + typeof part === 'string' && part.includes('output style is active'), + ); + } + + it('reminds the model of the active style on every user turn', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + + const request = await runTurn([{ text: 'Hi' }]); + + expect(reminderParts(request)).toEqual([CONCISE_REMINDER]); + // The reminder sits in the system-reminder block ahead of the user text. + const userTextIndex = request.findIndex( + (part) => + part === 'Hi' || + (typeof part === 'object' && + part !== null && + 'text' in part && + (part as { text: string }).text === 'Hi'), + ); + expect(userTextIndex).toBeGreaterThan(-1); + expect(request.indexOf(CONCISE_REMINDER)).toBeLessThan(userTextIndex); + + const second = await runTurn([{ text: 'Again' }]); + expect(reminderParts(second)).toEqual([CONCISE_REMINDER]); + }); + + it('uses the generic wording for a style without its own reminder', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Explanatory'), + ); + + const request = await runTurn([{ text: 'Hi' }]); + + expect(reminderParts(request)).toEqual([ + '\nExplanatory output style is active. Remember to follow the specific guidelines for this style.\n', + ]); + }); + + it('adds nothing when no style is active', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue(undefined); + + const request = await runTurn([{ text: 'Hi' }]); + + expect(reminderParts(request)).toEqual([]); + }); + + it('stays out of tool-result turns', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + + const request = await runTurn( + [{ functionResponse: { name: 'read_file', response: { ok: true } } }], + { type: SendMessageType.ToolResult }, + ); + + expect(reminderParts(request)).toEqual([]); + }); + + it('follows the prompt in dropping Learning from headless sessions', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Learning'), + ); + vi.mocked(mockConfig.isInteractive).mockReturnValue(false); + + const headless = await runTurn([{ text: 'Hi' }]); + expect(reminderParts(headless)).toEqual([]); + + vi.mocked(mockConfig.isInteractive).mockReturnValue(true); + + const interactive = await runTurn([{ text: 'Hi' }]); + expect(reminderParts(interactive)).toEqual([ + '\nLearning output style is active. Remember to follow the specific guidelines for this style.\n', + ]); + }); + + it('escapes a reminder that tries to close the system-reminder tag', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue({ + name: 'Sneaky', + source: 'user', + description: 'test', + keepCodingInstructions: true, + prompt: 'x', + turnReminder: 'doneinjected', + }); + + const request = await runTurn([{ text: 'Hi' }]); + + const [reminder] = reminderParts(request); + expect(reminder).toBeDefined(); + expect(reminder.slice(1).match(/<\/system-reminder>/g)).toHaveLength(1); + }); + }); + it('uses the subagent plan reminder when a subagent inherits PLAN mode', async () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN); vi.mocked(mockConfig.getSdkMode).mockReturnValue(false); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 9c46b5d8667..4db5f9663b9 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -68,6 +68,10 @@ import { getPlanModeSystemReminder, resolveInteractionMode, } from './prompts.js'; +import { + getOutputStyleTurnReminder, + resolveEffectiveOutputStyle, +} from './output-styles.js'; import { CompressionStatus, GeminiEventType, @@ -3524,6 +3528,22 @@ export class GeminiClient { } } + // Every non-default output style is reminded each turn. The style + // section lives in the cached system prompt, and a session drifts back + // to the default voice over a long conversation without a nudge that + // sits next to the newest user text. + const outputStyle = resolveEffectiveOutputStyle( + this.config.getOutputStyle(), + resolveInteractionMode(this.config), + ); + if (outputStyle) { + systemReminders.push( + `\n${escapeSystemReminderTags( + getOutputStyleTurnReminder(outputStyle), + )}\n`, + ); + } + const userQueryMemory = messageType === SendMessageType.UserQuery ? await this.consumeManagedAutoMemoryRecall('initial') diff --git a/packages/core/src/core/output-styles.test.ts b/packages/core/src/core/output-styles.test.ts index 871ba7ae8a5..ac81acd2864 100644 --- a/packages/core/src/core/output-styles.test.ts +++ b/packages/core/src/core/output-styles.test.ts @@ -12,6 +12,7 @@ import { getBuiltInOutputStyle, getOutputStyleTurnReminder, renderOutputStyleSection, + resolveEffectiveOutputStyle, type OutputStyleDefinition, } from './output-styles.js'; @@ -138,3 +139,31 @@ describe('applyOutputStyle', () => { ); }); }); + +describe('resolveEffectiveOutputStyle', () => { + const learning = getBuiltInOutputStyle('Learning')!; + const concise = getBuiltInOutputStyle('Concise')!; + + it('returns undefined when no style is active', () => { + expect( + resolveEffectiveOutputStyle(undefined, 'interactive'), + ).toBeUndefined(); + expect(resolveEffectiveOutputStyle(null, 'headless')).toBeUndefined(); + }); + + it('drops Learning in headless mode, where its handoff can never be answered', () => { + expect(resolveEffectiveOutputStyle(learning, 'headless')).toBeUndefined(); + }); + + it('keeps Learning where a reply can arrive', () => { + expect(resolveEffectiveOutputStyle(learning, 'interactive')).toBe(learning); + expect(resolveEffectiveOutputStyle(learning, 'acp')).toBe(learning); + }); + + it('keeps every other style in every mode', () => { + for (const mode of ['interactive', 'headless', 'acp'] as const) { + expect(resolveEffectiveOutputStyle(concise, mode)).toBe(concise); + expect(resolveEffectiveOutputStyle(LAYERED, mode)).toBe(LAYERED); + } + }); +}); diff --git a/packages/core/src/core/output-styles.ts b/packages/core/src/core/output-styles.ts index e955b579e53..bd049d30380 100644 --- a/packages/core/src/core/output-styles.ts +++ b/packages/core/src/core/output-styles.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { SystemPromptInteractionMode } from './prompts.js'; + /** * Where an output style came from. Only `built-in` is populated today; the * remaining sources exist so that user/project markdown files and extension @@ -140,6 +142,28 @@ export function getBuiltInOutputStyle( ); } +/** + * The style that actually applies for a given interaction mode. + * + * Learning hands the user a piece of code and then waits for their reply; a + * headless run cannot receive one, so the style is dropped there. This is the + * single source of truth for that rule: the system prompt and the per-turn + * reminder consult it together, so a session is never reminded about a style + * its prompt does not carry. + */ +export function resolveEffectiveOutputStyle( + style: OutputStyleDefinition | null | undefined, + interactionMode: SystemPromptInteractionMode, +): OutputStyleDefinition | undefined { + if (!style) { + return undefined; + } + if (interactionMode === 'headless' && style.name === 'Learning') { + return undefined; + } + return style; +} + /** * Renders the style section as it appears in the system prompt. * diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 9627b6e8773..36a2dd7adf6 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -14,7 +14,10 @@ import { QWEN_DIR } from '../config/storage.js'; import type { GenerateContentConfig } from '@google/genai'; import { InputFormat } from '../output/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import { applyOutputStyle } from './output-styles.js'; +import { + applyOutputStyle, + resolveEffectiveOutputStyle, +} from './output-styles.js'; import type { OutputStyleDefinition } from './output-styles.js'; const debugLogger = createDebugLogger('PROMPTS'); @@ -459,11 +462,10 @@ export function getCoreSystemPrompt( interactionMode: SystemPromptInteractionMode = 'interactive', outputStyle?: OutputStyleDefinition | null, ): string { - // Learning requires a reply to its handoff, which a headless run cannot receive. - const effectiveOutputStyle = - interactionMode === 'headless' && outputStyle?.name === 'Learning' - ? undefined - : outputStyle; + const effectiveOutputStyle = resolveEffectiveOutputStyle( + outputStyle, + interactionMode, + ); // if QWEN_SYSTEM_MD is set (and not 0|false), override system prompt from file // default path is .qwen/system.md (project-level), can be overridden via QWEN_SYSTEM_MD let systemMdEnabled = false; From 9dff60f23b55504720f97ee74974ae587f7422f7 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 19:43:20 +0800 Subject: [PATCH 02/11] fix(core): resolve the interaction mode only when a style is active Hand-written Config mocks that never set a style (client-goal.test.ts) should not need the interaction-mode accessors, and the default path should not pay for a mode lookup it does not use. Claude-Session: https://claude.ai/code/session_01Gk1gryVzWuW58zkBtoBvnM --- packages/core/src/core/client-goal.test.ts | 1 + packages/core/src/core/client.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index b21783661cd..45def7cd71e 100644 --- a/packages/core/src/core/client-goal.test.ts +++ b/packages/core/src/core/client-goal.test.ts @@ -229,6 +229,7 @@ function setupGoalClient() { toolResultsNumToKeep: 5, })), getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), + getOutputStyle: vi.fn(() => undefined), getSdkMode: vi.fn(() => false), getArenaManager: vi.fn(() => null), getFileHistoryService: vi.fn(() => ({ diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 4db5f9663b9..650dc1b44a7 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3532,10 +3532,13 @@ export class GeminiClient { // section lives in the cached system prompt, and a session drifts back // to the default voice over a long conversation without a nudge that // sits next to the newest user text. - const outputStyle = resolveEffectiveOutputStyle( - this.config.getOutputStyle(), - resolveInteractionMode(this.config), - ); + const activeStyle = this.config.getOutputStyle(); + const outputStyle = activeStyle + ? resolveEffectiveOutputStyle( + activeStyle, + resolveInteractionMode(this.config), + ) + : undefined; if (outputStyle) { systemReminders.push( `\n${escapeSystemReminderTags( From b31d8fe0fae63aa72eae0c02b281afacd7392aba Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 14:10:10 +0000 Subject: [PATCH 03/11] fix(core): skip the style reminder when the prompt carries no style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom systemPrompt routes through getCustomSystemPrompt() and a QWEN_SYSTEM_MD override replaces the base prompt verbatim — neither carries an output-style section, yet the per-turn reminder still fired for them. Gate the reminder on the same decision that puts the style section into the prompt: a shared isSystemMdActive() in prompts.ts, consulted by getCoreSystemPrompt() and the reminder site alike, plus the existing getSystemPrompt() override check. Also route the reminder envelope through the centralized wrapSystemReminder() instead of a third inline copy, and pin the intended cron-turn inclusion with a test. --- packages/core/src/core/client.test.ts | 49 ++++++++++++++++++++ packages/core/src/core/client.ts | 23 +++++---- packages/core/src/core/environmentContext.ts | 2 +- packages/core/src/core/prompts.ts | 25 +++++++--- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 7557de2363f..890442a069c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -8666,6 +8666,10 @@ hello }); describe('output style turn reminder', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + const CONCISE_REMINDER = '\nConcise output style is active. Be concise: answer first, cut the narration, keep only what the user needs.\n'; @@ -8791,6 +8795,51 @@ hello expect(reminder).toBeDefined(); expect(reminder.slice(1).match(/<\/system-reminder>/g)).toHaveLength(1); }); + + it('stays silent when a custom system prompt carries no style section', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + vi.mocked(mockConfig.getSystemPrompt).mockReturnValue('You are terse.'); + + const request = await runTurn([{ text: 'Hi' }]); + + expect(reminderParts(request)).toEqual([]); + }); + + it('stays silent while QWEN_SYSTEM_MD replaces the base prompt', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + vi.stubEnv('QWEN_SYSTEM_MD', 'true'); + + const request = await runTurn([{ text: 'Hi' }]); + + expect(reminderParts(request)).toEqual([]); + }); + + it('still reminds when QWEN_SYSTEM_MD is explicitly disabled', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + vi.stubEnv('QWEN_SYSTEM_MD', 'false'); + + const request = await runTurn([{ text: 'Hi' }]); + + expect(reminderParts(request)).toEqual([CONCISE_REMINDER]); + }); + + it('reminds on cron-fired turns', async () => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + + const request = await runTurn([{ text: 'Hi' }], { + type: SendMessageType.Cron, + }); + + expect(reminderParts(request)).toEqual([CONCISE_REMINDER]); + }); }); it('uses the subagent plan reminder when a subagent inherits PLAN mode', async () => { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 650dc1b44a7..2d626cbd6cb 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -66,6 +66,7 @@ import { getCoreSystemPrompt, getCustomSystemPrompt, getPlanModeSystemReminder, + isSystemMdActive, resolveInteractionMode, } from './prompts.js'; import { @@ -129,6 +130,7 @@ import { getDirectoryContextString, getInitialChatHistory, getStartupContextLength, + wrapSystemReminder, type AgentAvailabilityEntry, } from './environmentContext.js'; import { @@ -3531,19 +3533,20 @@ export class GeminiClient { // Every non-default output style is reminded each turn. The style // section lives in the cached system prompt, and a session drifts back // to the default voice over a long conversation without a nudge that - // sits next to the newest user text. + // sits next to the newest user text. Prompt overrides (a custom + // systemPrompt or QWEN_SYSTEM_MD) carry no style section, so those + // sessions get no reminder either. const activeStyle = this.config.getOutputStyle(); - const outputStyle = activeStyle - ? resolveEffectiveOutputStyle( - activeStyle, - resolveInteractionMode(this.config), - ) - : undefined; + const outputStyle = + activeStyle && !this.config.getSystemPrompt() && !isSystemMdActive() + ? resolveEffectiveOutputStyle( + activeStyle, + resolveInteractionMode(this.config), + ) + : undefined; if (outputStyle) { systemReminders.push( - `\n${escapeSystemReminderTags( - getOutputStyleTurnReminder(outputStyle), - )}\n`, + wrapSystemReminder(getOutputStyleTurnReminder(outputStyle)), ); } diff --git a/packages/core/src/core/environmentContext.ts b/packages/core/src/core/environmentContext.ts index 146820d2e03..bbd5d49c889 100644 --- a/packages/core/src/core/environmentContext.ts +++ b/packages/core/src/core/environmentContext.ts @@ -109,7 +109,7 @@ ${directoryContext} // outside the data-only framing. JSON.stringify in formatDeferredToolLine // neutralizes quotes/backticks/newlines but does NOT escape `<`/`>`, so // without this an MCP tool named `foobar` would break out. -function wrapSystemReminder(body: string): string { +export function wrapSystemReminder(body: string): string { return `${SYSTEM_REMINDER_OPEN}\n${escapeSystemReminderTags(body)}\n${SYSTEM_REMINDER_CLOSE}`; } diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 36a2dd7adf6..e08381977b4 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -202,6 +202,17 @@ export function resolvePathFromEnv(envVar?: string): { }; } +/** + * Whether `QWEN_SYSTEM_MD` replaces the base system prompt. The override is a + * full, user-owned prompt that carries no output-style section, so the prompt + * builders and the per-turn style reminder consult this together — a session + * is never reminded about a style its prompt does not carry. + */ +export function isSystemMdActive(): boolean { + const resolution = resolvePathFromEnv(process.env['QWEN_SYSTEM_MD']); + return resolution.value !== null && !resolution.isDisabled; +} + /** * Processes a custom system instruction by appending user memory if available. * This function should only be used when there is actually a custom instruction. @@ -468,17 +479,17 @@ export function getCoreSystemPrompt( ); // if QWEN_SYSTEM_MD is set (and not 0|false), override system prompt from file // default path is .qwen/system.md (project-level), can be overridden via QWEN_SYSTEM_MD - let systemMdEnabled = false; + const systemMdEnabled = isSystemMdActive(); let systemMdPath = path.resolve(path.join(QWEN_DIR, 'system.md')); - // Resolve the environment variable to get either a path or a switch value. - const systemMdResolution = resolvePathFromEnv(process.env['QWEN_SYSTEM_MD']); - // Proceed only if the environment variable is set and is not disabled. - if (systemMdResolution.value && !systemMdResolution.isDisabled) { - systemMdEnabled = true; + if (systemMdEnabled) { + // Resolve the environment variable to get either a path or a switch value. + const systemMdResolution = resolvePathFromEnv( + process.env['QWEN_SYSTEM_MD'], + ); // We update systemMdPath to this new custom path. - if (!systemMdResolution.isSwitch) { + if (!systemMdResolution.isSwitch && systemMdResolution.value) { systemMdPath = systemMdResolution.value; } From 5a45c9891680a9b4d656098a87cb85da7afd0ca9 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 14:49:40 +0000 Subject: [PATCH 04/11] fix(core): give write-file tests a unique per-run root dir A fixed /tmp/qwen-code-test-root breaks whenever a prior run by another user (e.g. a sandboxed root run on a shared runner) leaves the directory behind: mkdirSync(recursive) no-ops on the foreign directory and every write into it EACCESes. Reproduced the 80 deterministic-gate failures locally with a stale root-owned directory present; mkdtempSync isolates each run from any leftover state. --- packages/core/src/tools/write-file.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index c33475dfb37..8acd8f5dfcc 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -35,7 +35,10 @@ import { FileReadCache } from '../services/fileReadCache.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; import { CommitAttributionService } from '../services/commitAttribution.js'; -const rootDir = path.resolve(os.tmpdir(), 'qwen-code-test-root'); +// A unique per-run root: a fixed path under os.tmpdir() breaks whenever a +// previous run by another user (e.g. a sandboxed root run on a shared CI +// runner) leaves the directory behind, EACCES-ing every write into it. +const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-code-test-root-')); // --- MOCKS --- vi.mock('../core/client.js'); From b2dc15bbf5122b94c5b108ae51113f1943401c6f Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 18:15:12 +0000 Subject: [PATCH 05/11] refactor(core): centralize the turn-reminder style decision in prompts.ts Co-authored-by: Qwen-Coder --- packages/core/src/core/client-goal.test.ts | 3 + packages/core/src/core/client.test.ts | 109 ++++++++------------ packages/core/src/core/client.ts | 53 ++-------- packages/core/src/core/prompts.test.ts | 111 ++++++++++++++++++++- packages/core/src/core/prompts.ts | 52 ++++++++++ 5 files changed, 211 insertions(+), 117 deletions(-) diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index 45def7cd71e..a60672b39bf 100644 --- a/packages/core/src/core/client-goal.test.ts +++ b/packages/core/src/core/client-goal.test.ts @@ -229,7 +229,10 @@ function setupGoalClient() { toolResultsNumToKeep: 5, })), getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), + getSystemPrompt: vi.fn(() => undefined), getOutputStyle: vi.fn(() => undefined), + getExperimentalZedIntegration: vi.fn(() => false), + isInteractive: vi.fn(() => true), getSdkMode: vi.fn(() => false), getArenaManager: vi.fn(() => null), getFileHistoryService: vi.fn(() => ({ diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 890442a069c..c74f7f7209c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -80,8 +80,8 @@ vi.mock('../utils/retry.js', () => ({ isUnattendedMode: vi.fn(() => false), })); import { - getCoreSystemPrompt, getCustomSystemPrompt, + getMainSessionBaseSystemPrompt, getPlanModeSystemReminder, } from './prompts.js'; import { getBuiltInOutputStyle } from './output-styles.js'; @@ -175,6 +175,7 @@ vi.mock('./prompts', async (importOriginal) => { ...actual, getCustomSystemPrompt: vi.fn(), getCoreSystemPrompt: vi.fn(), + getMainSessionBaseSystemPrompt: vi.fn(), getCompressionPrompt: vi.fn(), getProjectSummaryPrompt: vi.fn(), getPlanModeSystemReminder: vi.fn(), @@ -487,7 +488,7 @@ describe('Gemini Client (client.ts)', () => { mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue({}); // The client concatenates these with the auto-memory suffix, so the // default mock must return a string, not undefined. - vi.mocked(getCoreSystemPrompt).mockReturnValue(''); + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue(''); vi.mocked(getCustomSystemPrompt).mockReturnValue(''); sessionStartProfilerMocks.profilers.length = 0; sessionStartProfilerMocks.createSessionStartProfiler.mockImplementation( @@ -1696,7 +1697,7 @@ describe('Gemini Client (client.ts)', () => { }); it('preserves existing system prompt suffixes when SessionStart additionalContext is applied', async () => { - vi.mocked(getCoreSystemPrompt).mockReturnValue( + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue( 'Base instruction\n\n---\n\nUser memory\n\n---\n\nAppended rule', ); const hookSystem = { @@ -1722,9 +1723,10 @@ describe('Gemini Client (client.ts)', () => { }); it('re-applies SessionStart additionalContext after refreshing the system instruction', async () => { - // startChat() calls getCoreSystemPrompt for the initial GeminiChat - // construction. The second call is refreshSystemInstruction under test. - vi.mocked(getCoreSystemPrompt) + // startChat() builds the main-session base prompt for the initial + // GeminiChat construction. The second call is refreshSystemInstruction + // under test. + vi.mocked(getMainSessionBaseSystemPrompt) .mockReturnValueOnce('Base instruction') .mockReturnValueOnce('Updated instruction'); const hookSystem = { @@ -1778,7 +1780,9 @@ describe('Gemini Client (client.ts)', () => { // and git status). Guard the append with a non-empty getAutoMemoryPrompt // so a future refactor dropping it fails here instead of silently // shipping a prompt without managed memory. - vi.mocked(getCoreSystemPrompt).mockReturnValue('Base instruction'); + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue( + 'Base instruction', + ); vi.mocked(mockConfig.getAutoMemoryPrompt).mockReturnValue( '# auto memory\nMEMORY_INDEX_MARKER', ); @@ -2278,12 +2282,12 @@ describe('Gemini Client (client.ts)', () => { .mockImplementation(() => {}); const addHistorySpy = vi.spyOn(client.getChat(), 'addHistory'); vi.spyOn(client.getChat(), 'setTools').mockImplementation(() => {}); - vi.mocked(getCoreSystemPrompt).mockClear(); + vi.mocked(getMainSessionBaseSystemPrompt).mockClear(); await client.setTools(); expect(setSystemInstructionSpy).not.toHaveBeenCalled(); - expect(vi.mocked(getCoreSystemPrompt)).not.toHaveBeenCalled(); + expect(vi.mocked(getMainSessionBaseSystemPrompt)).not.toHaveBeenCalled(); expect(buildChangedMcpToolsReminder).not.toHaveBeenCalled(); expect(addHistorySpy).not.toHaveBeenCalled(); @@ -2610,7 +2614,7 @@ describe('Gemini Client (client.ts)', () => { 'setSystemInstruction', ); vi.spyOn(client.getChat(), 'setTools').mockImplementation(() => {}); - vi.mocked(getCoreSystemPrompt).mockClear(); + vi.mocked(getMainSessionBaseSystemPrompt).mockClear(); await client.setTools(); @@ -2717,7 +2721,9 @@ describe('Gemini Client (client.ts)', () => { }); it('preserves SessionStart additionalContext because setTools does not rewrite the system instruction', async () => { - vi.mocked(getCoreSystemPrompt).mockReturnValue('Base instruction'); + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue( + 'Base instruction', + ); const hookSystem = { fireSessionStartEvent: vi.fn().mockResolvedValue( createHookOutput('SessionStart', { @@ -2771,7 +2777,9 @@ describe('Gemini Client (client.ts)', () => { describe('getMainSessionSystemInstruction', () => { it('records the gitStatus-free base as the static system prefix on Config', () => { - vi.mocked(getCoreSystemPrompt).mockReturnValueOnce('core base prompt'); + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( + 'core base prompt', + ); vi.mocked(getRecentGitStatus).mockReturnValueOnce('Git snapshot A'); const instruction = ( @@ -13477,6 +13485,10 @@ Other open files: const generationConfig = { temperature: 0.5 }; const abortSignal = new AbortController().signal; + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( + 'Main session base', + ); + await client.generateContent( contents, generationConfig, @@ -13489,7 +13501,7 @@ Other open files: model: DEFAULT_QWEN_FLASH_MODEL, config: expect.objectContaining({ abortSignal, - systemInstruction: getCoreSystemPrompt(''), + systemInstruction: 'Main session base', temperature: 0.5, }), contents, @@ -13679,7 +13691,7 @@ Other open files: vi.spyOn(client['config'], 'getUserMemory').mockReturnValue( 'Saved memory', ); - vi.mocked(getCustomSystemPrompt).mockReturnValueOnce( + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( 'Override prompt with memory', ); @@ -13691,8 +13703,8 @@ Other open files: ); // The override is the stable base only; user memory flows through - // assembleSystemPrompt as the context layer. - expect(getCustomSystemPrompt).toHaveBeenCalledWith('Override prompt'); + // assembleSystemPrompt as the context layer. Routing the override to + // the custom base is pinned by prompts.test.ts. expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -13708,7 +13720,6 @@ Other open files: const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; const abortSignal = new AbortController().signal; - vi.mocked(getCoreSystemPrompt).mockClear(); vi.spyOn(client['config'], 'getAppendSystemPrompt').mockReturnValue( 'Be extra concise.', ); @@ -13720,15 +13731,8 @@ Other open files: DEFAULT_QWEN_FLASH_MODEL, ); - // The core prompt is requested as the stable base only; the append - // prompt flows through assembleSystemPrompt as a context-layer slot. - expect(getCoreSystemPrompt).toHaveBeenCalledWith( - undefined, - 'test-model', - undefined, - 'headless', - undefined, - ); + // The prompt-layer base is the stable base only; the append prompt + // flows through assembleSystemPrompt as a context-layer slot. expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -13739,12 +13743,12 @@ Other open files: ); }); - it('passes the active output style to the core system prompt', async () => { + it('hands the live config to the prompt layer for the style decision', async () => { const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; const abortSignal = new AbortController().signal; const concise = getBuiltInOutputStyle('Concise'); - vi.mocked(getCoreSystemPrompt).mockClear(); + vi.mocked(getMainSessionBaseSystemPrompt).mockClear(); vi.spyOn(client['config'], 'getOutputStyle').mockReturnValue(concise); await client.generateContent( @@ -13754,48 +13758,14 @@ Other open files: DEFAULT_QWEN_FLASH_MODEL, ); - expect(getCoreSystemPrompt).toHaveBeenCalledWith( - undefined, - 'test-model', - undefined, - 'headless', - concise, + // Style and interaction-mode resolution live in the prompt layer + // (resolveMainSessionOutputStyle, pinned by prompts.test.ts); the + // client's contract is to hand it the live config. + expect(getMainSessionBaseSystemPrompt).toHaveBeenCalledWith( + client['config'], ); }); - it.each([ - ['interactive', true, false], - ['acp', false, true], - ['headless', false, false], - ] as const)( - 'should pass %s mode to the core system prompt', - async (mode, interactive, acp) => { - const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; - const abortSignal = new AbortController().signal; - - vi.mocked(getCoreSystemPrompt).mockClear(); - vi.mocked(client['config'].isInteractive).mockReturnValue(interactive); - vi.mocked( - client['config'].getExperimentalZedIntegration, - ).mockReturnValue(acp); - - await client.generateContent( - contents, - {}, - abortSignal, - DEFAULT_QWEN_FLASH_MODEL, - ); - - expect(getCoreSystemPrompt).toHaveBeenCalledWith( - undefined, - 'test-model', - undefined, - mode, - undefined, - ); - }, - ); - it('should append config appendSystemPrompt after a config system prompt override', async () => { const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; const abortSignal = new AbortController().signal; @@ -13809,7 +13779,7 @@ Other open files: vi.spyOn(client['config'], 'getUserMemory').mockReturnValue( 'Saved memory', ); - vi.mocked(getCustomSystemPrompt).mockReturnValueOnce( + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( 'Override prompt with memory and append', ); @@ -13823,7 +13793,6 @@ Other open files: // The override is the stable base; memory and append flow through // assembleSystemPrompt in canonical layer order (context files before // the append prompt). - expect(getCustomSystemPrompt).toHaveBeenCalledWith('Override prompt'); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -13841,7 +13810,7 @@ Other open files: vi.mocked(getRecentGitStatus).mockReturnValue('Git snapshot cached'); vi.mocked(getRecentGitStatus).mockClear(); - vi.mocked(getCoreSystemPrompt).mockReturnValue('Core prompt'); + vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue('Core prompt'); await client.generateContent( contents, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2d626cbd6cb..26eb76d02a1 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -63,16 +63,12 @@ import { getRecentGitStatus } from '../utils/gitUtils.js'; import { assembleSystemPrompt, getArenaSystemReminder, - getCoreSystemPrompt, getCustomSystemPrompt, + getMainSessionBaseSystemPrompt, getPlanModeSystemReminder, - isSystemMdActive, - resolveInteractionMode, + resolveMainSessionOutputStyle, } from './prompts.js'; -import { - getOutputStyleTurnReminder, - resolveEffectiveOutputStyle, -} from './output-styles.js'; +import { getOutputStyleTurnReminder } from './output-styles.js'; import { CompressionStatus, GeminiEventType, @@ -355,31 +351,6 @@ const SKILL_WRITE_TOOL_NAMES: ReadonlySet = new Set([ ToolNames.EDIT, ]); -type MainSessionPromptConfig = Pick< - Config, - | 'getSystemPrompt' - | 'getModel' - | 'getOutputStyle' - | 'getExperimentalZedIntegration' - | 'getInputFormat' - | 'isInteractive' ->; - -export function getMainSessionBaseSystemPrompt( - config: MainSessionPromptConfig, -): string { - const overrideSystemPrompt = config.getSystemPrompt(); - return overrideSystemPrompt - ? getCustomSystemPrompt(overrideSystemPrompt) - : getCoreSystemPrompt( - undefined, - config.getModel(), - undefined, - resolveInteractionMode(config), - config.getOutputStyle(), - ); -} - export class GeminiClient { private chat?: GeminiChat; private initializedSessionId: string | undefined; @@ -3530,20 +3501,10 @@ export class GeminiClient { } } - // Every non-default output style is reminded each turn. The style - // section lives in the cached system prompt, and a session drifts back - // to the default voice over a long conversation without a nudge that - // sits next to the newest user text. Prompt overrides (a custom - // systemPrompt or QWEN_SYSTEM_MD) carry no style section, so those - // sessions get no reminder either. - const activeStyle = this.config.getOutputStyle(); - const outputStyle = - activeStyle && !this.config.getSystemPrompt() && !isSystemMdActive() - ? resolveEffectiveOutputStyle( - activeStyle, - resolveInteractionMode(this.config), - ) - : undefined; + // Remind the model of the style its system prompt carries: the + // section sits in the cached prompt and fades over a long + // conversation without a nudge next to the newest user text. + const outputStyle = resolveMainSessionOutputStyle(this.config); if (outputStyle) { systemReminders.push( wrapSystemReminder(getOutputStyleTurnReminder(outputStyle)), diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 088ec7a55c0..f4584e4c1c5 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -4,20 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { assembleSystemPrompt, getCoreSystemPrompt, getCustomSystemPrompt, + getMainSessionBaseSystemPrompt, getManualPlanExitSystemReminder, getPlanModeSystemReminder, resolvePathFromEnv, getCompressionPrompt, resolveInteractionMode, + resolveMainSessionOutputStyle, } from './prompts.js'; import { BUILT_IN_OUTPUT_STYLES, getBuiltInOutputStyle, + type OutputStyleDefinition, } from './output-styles.js'; import { InputFormat } from '../output/types.js'; import { isGitRepository } from '../utils/gitUtils.js'; @@ -782,6 +785,112 @@ describe('Core System Prompt (prompts.ts)', () => { }); }); +describe('main-session style: reminder decision matches prompt section', () => { + const concise = getBuiltInOutputStyle('Concise')!; + const learning = getBuiltInOutputStyle('Learning')!; + + const sessions = [ + ['interactive', { interactive: true, acp: false }], + ['headless', { interactive: false, acp: false }], + ['acp', { interactive: false, acp: true }], + ] as const; + + const makeConfig = (opts: { + customPrompt?: string; + style?: OutputStyleDefinition; + interactive: boolean; + acp: boolean; + }) => ({ + getSystemPrompt: () => opts.customPrompt, + getModel: () => 'test-model', + getOutputStyle: () => opts.style, + getExperimentalZedIntegration: () => opts.acp, + getInputFormat: () => InputFormat.TEXT, + isInteractive: () => opts.interactive, + }); + + beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv('QWEN_SYSTEM_MD', undefined); + vi.stubEnv('QWEN_SYSTEM_IDENTITY_MD', undefined); + vi.stubEnv('QWEN_WRITE_SYSTEM_MD', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each(sessions)( + 'renders the %s interaction mode the config resolves to', + (session, flags) => { + const markers = { + interactive: 'an interactive CLI agent', + headless: 'a non-interactive CLI agent', + acp: 'a CLI agent operating through an ACP host', + } as const; + expect(getMainSessionBaseSystemPrompt(makeConfig(flags))).toContain( + markers[session], + ); + }, + ); + + interface Case { + name: string; + customPrompt?: string; + systemMd?: string; + style?: OutputStyleDefinition; + flags: { interactive: boolean; acp: boolean }; + } + + const cases: Case[] = []; + for (const customPrompt of [undefined, 'You are terse.']) { + for (const systemMd of [undefined, 'true']) { + for (const style of [undefined, concise, learning]) { + for (const [session, flags] of sessions) { + cases.push({ + name: + `custom=${customPrompt ? 'yes' : 'no'} ` + + `systemMd=${systemMd ?? 'off'} ` + + `style=${style?.name ?? 'none'} session=${session}`, + customPrompt, + systemMd, + style, + flags, + }); + } + } + } + } + + // The per-turn gate in GeminiClient is exactly + // resolveMainSessionOutputStyle(config), so pinning that decision against + // the rendered prompt means the reminder and the prompt cannot drift when + // a new prompt condition is added. The client-side wiring is pinned by the + // reminder tests in client.test.ts. + it.each(cases)( + 'reminds if and only if the prompt carries the style section ($name)', + ({ customPrompt, systemMd, style, flags }) => { + vi.stubEnv('QWEN_SYSTEM_MD', systemMd); + if (systemMd) { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('custom system prompt'); + } + + const config = makeConfig({ customPrompt, style, ...flags }); + const reminded = resolveMainSessionOutputStyle(config) !== undefined; + const prompt = getMainSessionBaseSystemPrompt(config); + + expect(reminded).toBe(prompt.includes('# Output Style:')); + if (customPrompt) { + // The override replaces the base verbatim. + expect(prompt).toContain(customPrompt); + } else if (!systemMd) { + expect(prompt).toContain('You are Qwen Code'); + } + }, + ); +}); + describe('Model-specific tool call formats', () => { beforeEach(() => { vi.resetAllMocks(); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index e08381977b4..7e6e4bede7d 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -11,6 +11,7 @@ import { ToolNames } from '../tools/tool-names.js'; import process from 'node:process'; import { isGitRepository } from '../utils/gitUtils.js'; import { QWEN_DIR } from '../config/storage.js'; +import type { Config } from '../config/config.js'; import type { GenerateContentConfig } from '@google/genai'; import { InputFormat } from '../output/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -451,6 +452,57 @@ Interaction mode reminder: ${interaction.questions} `.trim(); } +type MainSessionPromptConfig = Pick< + Config, + | 'getSystemPrompt' + | 'getModel' + | 'getOutputStyle' + | 'getExperimentalZedIntegration' + | 'getInputFormat' + | 'isInteractive' +>; + +/** + * The output style a main session's prompt actually carries — the single + * decision the prompt builders and the per-turn style reminder consult + * together, so a session is never reminded about a style its prompt does not + * carry. Prompt overrides own their wording end to end: neither a custom + * `systemPrompt` nor a `QWEN_SYSTEM_MD` replacement gets a style section, so + * neither gets a reminder. Uses a structural type, like + * {@link resolveInteractionMode}, to avoid a hard dependency on the full + * Config class. + */ +export function resolveMainSessionOutputStyle(config: { + getSystemPrompt(): string | undefined; + getOutputStyle(): OutputStyleDefinition | null | undefined; + getExperimentalZedIntegration(): boolean; + getInputFormat?(): string; + isInteractive(): boolean; +}): OutputStyleDefinition | undefined { + if (config.getSystemPrompt() || isSystemMdActive()) { + return undefined; + } + return resolveEffectiveOutputStyle( + config.getOutputStyle(), + resolveInteractionMode(config), + ); +} + +export function getMainSessionBaseSystemPrompt( + config: MainSessionPromptConfig, +): string { + const overrideSystemPrompt = config.getSystemPrompt(); + return overrideSystemPrompt + ? getCustomSystemPrompt(overrideSystemPrompt) + : getCoreSystemPrompt( + undefined, + config.getModel(), + undefined, + resolveInteractionMode(config), + resolveMainSessionOutputStyle(config), + ); +} + /** * Builds the stable base system prompt (identity, mandates, tool guidance). * From b90e28d99bd5b24761e44fb50416b3a6ff155aa7 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 27 Aug 2026 20:49:41 +0000 Subject: [PATCH 06/11] test(core): pin the main-session override replacement and model forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompts.test.ts matrix only asserted that a custom systemPrompt override appears in the rendered prompt, never that it replaces the default base — and the config.getModel() forwarding in getMainSessionBaseSystemPrompt had no witness after the client-side assertions moved to the prompt layer. Both mutants (layering the override on the base; dropping the model forward) shipped green in a scratch probe. Add the replacement assertion and an end-to-end coder-model case; each now kills its mutant. Co-authored-by: Qwen-Coder --- packages/core/src/core/prompts.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index f4584e4c1c5..a1e6ef5c4ef 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -884,11 +884,23 @@ describe('main-session style: reminder decision matches prompt section', () => { if (customPrompt) { // The override replaces the base verbatim. expect(prompt).toContain(customPrompt); + expect(prompt).not.toContain('You are Qwen Code'); } else if (!systemMd) { expect(prompt).toContain('You are Qwen Code'); } }, ); + + it('forwards the config model to the base prompt', () => { + const config = { + ...makeConfig({ interactive: true, acp: false }), + getModel: () => 'qwen3-coder-7b', + }; + + expect(getMainSessionBaseSystemPrompt(config)).toContain( + '', + ); + }); }); describe('Model-specific tool call formats', () => { From 79c8b55e4479b4bc3bd6e0988043833fbb3fb8f1 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 11:50:57 +0800 Subject: [PATCH 07/11] refactor(core): keep getMainSessionBaseSystemPrompt in client.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output-style work had moved `getMainSessionBaseSystemPrompt` and its `MainSessionPromptConfig` type from client.ts to prompts.ts. The move was incidental to this PR — it widened the diff across two modules and rewrote every `getCoreSystemPrompt` mock in client.test.ts into a `getMainSessionBaseSystemPrompt` one, none of which the feature needs. The function is back where main keeps it, carrying the one change the feature does require: it resolves the style through `resolveMainSessionOutputStyle(config)` rather than `config.getOutputStyle()`, so the prompt and the per-turn reminder cannot disagree about which style is in force. `resolveMainSessionOutputStyle` and `isSystemMdActive` stay in prompts.ts, where they are new rather than moved. client.test.ts is main's file plus the reminder tests; prompts.test.ts pins the prompt against the resolver by importing the builder from client.js. Claude-Session: https://claude.ai/code/session_018dYE4LwSMeMPFchXk5UBdM --- packages/core/src/core/client.test.ts | 109 ++++++++++++++++--------- packages/core/src/core/client.ts | 32 +++++++- packages/core/src/core/prompts.test.ts | 5 +- packages/core/src/core/prompts.ts | 26 ------ 4 files changed, 105 insertions(+), 67 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e046dd29dcd..5cea716e604 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -80,8 +80,8 @@ vi.mock('../utils/retry.js', () => ({ isUnattendedMode: vi.fn(() => false), })); import { + getCoreSystemPrompt, getCustomSystemPrompt, - getMainSessionBaseSystemPrompt, getPlanModeSystemReminder, } from './prompts.js'; import { getBuiltInOutputStyle } from './output-styles.js'; @@ -175,7 +175,6 @@ vi.mock('./prompts', async (importOriginal) => { ...actual, getCustomSystemPrompt: vi.fn(), getCoreSystemPrompt: vi.fn(), - getMainSessionBaseSystemPrompt: vi.fn(), getCompressionPrompt: vi.fn(), getProjectSummaryPrompt: vi.fn(), getPlanModeSystemReminder: vi.fn(), @@ -488,7 +487,7 @@ describe('Gemini Client (client.ts)', () => { mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue({}); // The client concatenates these with the auto-memory suffix, so the // default mock must return a string, not undefined. - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue(''); + vi.mocked(getCoreSystemPrompt).mockReturnValue(''); vi.mocked(getCustomSystemPrompt).mockReturnValue(''); sessionStartProfilerMocks.profilers.length = 0; sessionStartProfilerMocks.createSessionStartProfiler.mockImplementation( @@ -1697,7 +1696,7 @@ describe('Gemini Client (client.ts)', () => { }); it('preserves existing system prompt suffixes when SessionStart additionalContext is applied', async () => { - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue( + vi.mocked(getCoreSystemPrompt).mockReturnValue( 'Base instruction\n\n---\n\nUser memory\n\n---\n\nAppended rule', ); const hookSystem = { @@ -1723,10 +1722,9 @@ describe('Gemini Client (client.ts)', () => { }); it('re-applies SessionStart additionalContext after refreshing the system instruction', async () => { - // startChat() builds the main-session base prompt for the initial - // LlmChat construction. The second call is refreshSystemInstruction - // under test. - vi.mocked(getMainSessionBaseSystemPrompt) + // startChat() calls getCoreSystemPrompt for the initial LlmChat + // construction. The second call is refreshSystemInstruction under test. + vi.mocked(getCoreSystemPrompt) .mockReturnValueOnce('Base instruction') .mockReturnValueOnce('Updated instruction'); const hookSystem = { @@ -1780,9 +1778,7 @@ describe('Gemini Client (client.ts)', () => { // and git status). Guard the append with a non-empty getAutoMemoryPrompt // so a future refactor dropping it fails here instead of silently // shipping a prompt without managed memory. - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue( - 'Base instruction', - ); + vi.mocked(getCoreSystemPrompt).mockReturnValue('Base instruction'); vi.mocked(mockConfig.getAutoMemoryPrompt).mockReturnValue( '# auto memory\nMEMORY_INDEX_MARKER', ); @@ -2282,12 +2278,12 @@ describe('Gemini Client (client.ts)', () => { .mockImplementation(() => {}); const addHistorySpy = vi.spyOn(client.getChat(), 'addHistory'); vi.spyOn(client.getChat(), 'setTools').mockImplementation(() => {}); - vi.mocked(getMainSessionBaseSystemPrompt).mockClear(); + vi.mocked(getCoreSystemPrompt).mockClear(); await client.setTools(); expect(setSystemInstructionSpy).not.toHaveBeenCalled(); - expect(vi.mocked(getMainSessionBaseSystemPrompt)).not.toHaveBeenCalled(); + expect(vi.mocked(getCoreSystemPrompt)).not.toHaveBeenCalled(); expect(buildChangedMcpToolsReminder).not.toHaveBeenCalled(); expect(addHistorySpy).not.toHaveBeenCalled(); @@ -2614,7 +2610,7 @@ describe('Gemini Client (client.ts)', () => { 'setSystemInstruction', ); vi.spyOn(client.getChat(), 'setTools').mockImplementation(() => {}); - vi.mocked(getMainSessionBaseSystemPrompt).mockClear(); + vi.mocked(getCoreSystemPrompt).mockClear(); await client.setTools(); @@ -2721,9 +2717,7 @@ describe('Gemini Client (client.ts)', () => { }); it('preserves SessionStart additionalContext because setTools does not rewrite the system instruction', async () => { - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue( - 'Base instruction', - ); + vi.mocked(getCoreSystemPrompt).mockReturnValue('Base instruction'); const hookSystem = { fireSessionStartEvent: vi.fn().mockResolvedValue( createHookOutput('SessionStart', { @@ -2777,9 +2771,7 @@ describe('Gemini Client (client.ts)', () => { describe('getMainSessionSystemInstruction', () => { it('records the gitStatus-free base as the static system prefix on Config', () => { - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( - 'core base prompt', - ); + vi.mocked(getCoreSystemPrompt).mockReturnValueOnce('core base prompt'); vi.mocked(getRecentGitStatus).mockReturnValueOnce('Git snapshot A'); const instruction = ( @@ -13485,10 +13477,6 @@ Other open files: const generationConfig = { temperature: 0.5 }; const abortSignal = new AbortController().signal; - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( - 'Main session base', - ); - await client.generateContent( contents, generationConfig, @@ -13501,7 +13489,7 @@ Other open files: model: DEFAULT_QWEN_FLASH_MODEL, config: expect.objectContaining({ abortSignal, - systemInstruction: 'Main session base', + systemInstruction: getCoreSystemPrompt(''), temperature: 0.5, }), contents, @@ -13691,7 +13679,7 @@ Other open files: vi.spyOn(client['config'], 'getUserMemory').mockReturnValue( 'Saved memory', ); - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( + vi.mocked(getCustomSystemPrompt).mockReturnValueOnce( 'Override prompt with memory', ); @@ -13703,8 +13691,8 @@ Other open files: ); // The override is the stable base only; user memory flows through - // assembleSystemPrompt as the context layer. Routing the override to - // the custom base is pinned by prompts.test.ts. + // assembleSystemPrompt as the context layer. + expect(getCustomSystemPrompt).toHaveBeenCalledWith('Override prompt'); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -13720,6 +13708,7 @@ Other open files: const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; const abortSignal = new AbortController().signal; + vi.mocked(getCoreSystemPrompt).mockClear(); vi.spyOn(client['config'], 'getAppendSystemPrompt').mockReturnValue( 'Be extra concise.', ); @@ -13731,8 +13720,15 @@ Other open files: DEFAULT_QWEN_FLASH_MODEL, ); - // The prompt-layer base is the stable base only; the append prompt - // flows through assembleSystemPrompt as a context-layer slot. + // The core prompt is requested as the stable base only; the append + // prompt flows through assembleSystemPrompt as a context-layer slot. + expect(getCoreSystemPrompt).toHaveBeenCalledWith( + undefined, + 'test-model', + undefined, + 'headless', + undefined, + ); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -13743,12 +13739,12 @@ Other open files: ); }); - it('hands the live config to the prompt layer for the style decision', async () => { + it('passes the active output style to the core system prompt', async () => { const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; const abortSignal = new AbortController().signal; const concise = getBuiltInOutputStyle('Concise'); - vi.mocked(getMainSessionBaseSystemPrompt).mockClear(); + vi.mocked(getCoreSystemPrompt).mockClear(); vi.spyOn(client['config'], 'getOutputStyle').mockReturnValue(concise); await client.generateContent( @@ -13758,14 +13754,48 @@ Other open files: DEFAULT_QWEN_FLASH_MODEL, ); - // Style and interaction-mode resolution live in the prompt layer - // (resolveMainSessionOutputStyle, pinned by prompts.test.ts); the - // client's contract is to hand it the live config. - expect(getMainSessionBaseSystemPrompt).toHaveBeenCalledWith( - client['config'], + expect(getCoreSystemPrompt).toHaveBeenCalledWith( + undefined, + 'test-model', + undefined, + 'headless', + concise, ); }); + it.each([ + ['interactive', true, false], + ['acp', false, true], + ['headless', false, false], + ] as const)( + 'should pass %s mode to the core system prompt', + async (mode, interactive, acp) => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const abortSignal = new AbortController().signal; + + vi.mocked(getCoreSystemPrompt).mockClear(); + vi.mocked(client['config'].isInteractive).mockReturnValue(interactive); + vi.mocked( + client['config'].getExperimentalZedIntegration, + ).mockReturnValue(acp); + + await client.generateContent( + contents, + {}, + abortSignal, + DEFAULT_QWEN_FLASH_MODEL, + ); + + expect(getCoreSystemPrompt).toHaveBeenCalledWith( + undefined, + 'test-model', + undefined, + mode, + undefined, + ); + }, + ); + it('should append config appendSystemPrompt after a config system prompt override', async () => { const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; const abortSignal = new AbortController().signal; @@ -13779,7 +13809,7 @@ Other open files: vi.spyOn(client['config'], 'getUserMemory').mockReturnValue( 'Saved memory', ); - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValueOnce( + vi.mocked(getCustomSystemPrompt).mockReturnValueOnce( 'Override prompt with memory and append', ); @@ -13793,6 +13823,7 @@ Other open files: // The override is the stable base; memory and append flow through // assembleSystemPrompt in canonical layer order (context files before // the append prompt). + expect(getCustomSystemPrompt).toHaveBeenCalledWith('Override prompt'); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -13810,7 +13841,7 @@ Other open files: vi.mocked(getRecentGitStatus).mockReturnValue('Git snapshot cached'); vi.mocked(getRecentGitStatus).mockClear(); - vi.mocked(getMainSessionBaseSystemPrompt).mockReturnValue('Core prompt'); + vi.mocked(getCoreSystemPrompt).mockReturnValue('Core prompt'); await client.generateContent( contents, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 8464c204603..c6db78876ce 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -63,9 +63,10 @@ import { getRecentGitStatus } from '../utils/gitUtils.js'; import { assembleSystemPrompt, getArenaSystemReminder, + getCoreSystemPrompt, getCustomSystemPrompt, - getMainSessionBaseSystemPrompt, getPlanModeSystemReminder, + resolveInteractionMode, resolveMainSessionOutputStyle, } from './prompts.js'; import { getOutputStyleTurnReminder } from './output-styles.js'; @@ -348,6 +349,35 @@ const SKILL_WRITE_TOOL_NAMES: ReadonlySet = new Set([ ToolNames.EDIT, ]); +type MainSessionPromptConfig = Pick< + Config, + | 'getSystemPrompt' + | 'getModel' + | 'getOutputStyle' + | 'getExperimentalZedIntegration' + | 'getInputFormat' + | 'isInteractive' +>; + +export function getMainSessionBaseSystemPrompt( + config: MainSessionPromptConfig, +): string { + const overrideSystemPrompt = config.getSystemPrompt(); + return overrideSystemPrompt + ? getCustomSystemPrompt(overrideSystemPrompt) + : getCoreSystemPrompt( + undefined, + config.getModel(), + undefined, + resolveInteractionMode(config), + // The prompt and the per-turn reminder must agree on which style is + // in force, so both read it from the same resolver rather than from + // `getOutputStyle()` directly — a prompt override carries no style + // section, and a session must not be reminded of one it lacks. + resolveMainSessionOutputStyle(config), + ); +} + export class LlmClient { private chat?: LlmChat; private initializedSessionId: string | undefined; diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 153f03f5bf3..34463143bd0 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -9,7 +9,6 @@ import { assembleSystemPrompt, getCoreSystemPrompt, getCustomSystemPrompt, - getMainSessionBaseSystemPrompt, getManualPlanExitSystemReminder, getPlanModeSystemReminder, resolvePathFromEnv, @@ -17,6 +16,10 @@ import { resolveInteractionMode, resolveMainSessionOutputStyle, } from './prompts.js'; +// The base-prompt builder lives with the client that calls it; these tests +// pin it against the resolver here so the prompt and the per-turn reminder +// cannot drift apart. +import { getMainSessionBaseSystemPrompt } from './client.js'; import { BUILT_IN_OUTPUT_STYLES, getBuiltInOutputStyle, diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 7e6e4bede7d..1c43eccbf43 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -11,7 +11,6 @@ import { ToolNames } from '../tools/tool-names.js'; import process from 'node:process'; import { isGitRepository } from '../utils/gitUtils.js'; import { QWEN_DIR } from '../config/storage.js'; -import type { Config } from '../config/config.js'; import type { GenerateContentConfig } from '@google/genai'; import { InputFormat } from '../output/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -452,16 +451,6 @@ Interaction mode reminder: ${interaction.questions} `.trim(); } -type MainSessionPromptConfig = Pick< - Config, - | 'getSystemPrompt' - | 'getModel' - | 'getOutputStyle' - | 'getExperimentalZedIntegration' - | 'getInputFormat' - | 'isInteractive' ->; - /** * The output style a main session's prompt actually carries — the single * decision the prompt builders and the per-turn style reminder consult @@ -488,21 +477,6 @@ export function resolveMainSessionOutputStyle(config: { ); } -export function getMainSessionBaseSystemPrompt( - config: MainSessionPromptConfig, -): string { - const overrideSystemPrompt = config.getSystemPrompt(); - return overrideSystemPrompt - ? getCustomSystemPrompt(overrideSystemPrompt) - : getCoreSystemPrompt( - undefined, - config.getModel(), - undefined, - resolveInteractionMode(config), - resolveMainSessionOutputStyle(config), - ); -} - /** * Builds the stable base system prompt (identity, mandates, tool guidance). * From bdc53fa77a3f672024cf41559059c188c0a379b7 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 28 Aug 2026 08:29:31 +0000 Subject: [PATCH 08/11] test(core): stub QWEN_CODE_TOOL_CALL_STYLE in the main-session prompt suite The 'forwards the config model to the base prompt' pin asserts on model-detected tool call examples, but getToolCallExamples consults QWEN_CODE_TOOL_CALL_STYLE before model detection. Without the stub, an exported value makes the pin vacuous (qwen-coder) or spuriously red (general). --- packages/core/src/core/prompts.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 34463143bd0..9c899687204 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -817,6 +817,7 @@ describe('main-session style: reminder decision matches prompt section', () => { vi.stubEnv('QWEN_SYSTEM_MD', undefined); vi.stubEnv('QWEN_SYSTEM_IDENTITY_MD', undefined); vi.stubEnv('QWEN_WRITE_SYSTEM_MD', undefined); + vi.stubEnv('QWEN_CODE_TOOL_CALL_STYLE', undefined); }); afterEach(() => { From 779902b038b07d9ea08bbb69e567a7c8caf500ba Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 29 Aug 2026 14:45:14 +0800 Subject: [PATCH 09/11] fix(acp): send the output-style turn reminder on ACP prompts too The ACP session assembles its own per-turn reminders because it calls the chat directly and bypasses LlmClient.sendMessageStream; the style reminder was missing from that list, so an ACP prompt carried the style section but never the nudge. It now reads the same resolveMainSessionOutputStyle() decision as the prompt, so a custom system prompt or QWEN_SYSTEM_MD keeps it silent there as well. Also pins that Retry, Notification and Teammate turns carry no style reminder. Claude-Session: https://claude.ai/code/session_01Gk1gryVzWuW58zkBtoBvnM --- .../acp-integration/session/Session.test.ts | 69 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 15 ++++ packages/core/src/core/client.test.ts | 17 +++++ 3 files changed, 101 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index afd28a48bcd..51d62422fc1 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4062,6 +4062,75 @@ describe('Session', () => { }); }); + describe('output style turn reminder', () => { + function armStyle(styleName: string | undefined) { + mockConfig.getOutputStyle = vi + .fn() + .mockReturnValue( + styleName ? core.getBuiltInOutputStyle(styleName) : undefined, + ); + mockConfig.getSystemPrompt = vi.fn().mockReturnValue(undefined); + mockConfig.getExperimentalZedIntegration = vi.fn().mockReturnValue(true); + mockConfig.isInteractive = vi.fn().mockReturnValue(false); + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'ok' }] } }], + }, + }, + ]), + ); + } + + it('sends the active style reminder with every ACP prompt', async () => { + armStyle('Concise'); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hi' }], + }); + + expect(textParts(firstSentMessage())).toContainEqual( + expect.stringMatching( + /^\nConcise output style is active\. Be concise:.*\n<\/system-reminder>$/s, + ), + ); + }); + + it('sends nothing when no style is active', async () => { + armStyle(undefined); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hi' }], + }); + + expect( + textParts(firstSentMessage()).some((text) => + text.includes('output style is active'), + ), + ).toBe(false); + }); + + it('stays silent when a custom system prompt carries no style section', async () => { + armStyle('Concise'); + mockConfig.getSystemPrompt = vi.fn().mockReturnValue('You are terse.'); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hi' }], + }); + + expect( + textParts(firstSentMessage()).some((text) => + text.includes('output style is active'), + ), + ).toBe(false); + }); + }); + describe('sendCurrentModeUpdateNotification', () => { // The exit_plan_mode / edit-ProceedAlways path publishes the legacy // `session_update{current_mode_update}` frame itself (via sendUpdate), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2e19ef3935a..a7c7ee10ce9 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -106,6 +106,9 @@ import { MessageDisplayDispatcher, getPlanModeSystemReminder, getArenaSystemReminder, + getOutputStyleTurnReminder, + resolveMainSessionOutputStyle, + wrapSystemReminder, getStartupContextLength, isSystemReminderContent, buildSessionRecoveryPlanFromApiHistory, @@ -10386,6 +10389,18 @@ export class Session implements SessionContext { } } + // The output-style reminder, exactly as `LlmClient.sendMessageStream` + // sends it: the ACP prompt carries the style section, so it needs the + // same per-turn nudge or the style fades over a long session. + if (this.config.getOutputStyle?.()) { + const outputStyle = resolveMainSessionOutputStyle(this.config); + if (outputStyle) { + reminders.push({ + text: wrapSystemReminder(getOutputStyleTurnReminder(outputStyle)), + }); + } + } + return reminders; } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 0ceb32b9b58..e27c2cc0961 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -8799,6 +8799,9 @@ hello client['chat'] = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + // Retry turns strip orphaned user entries before sending. + getHistoryLength: vi.fn().mockReturnValue(0), + stripOrphanedUserEntriesFromHistory: vi.fn().mockReturnValue([]), } as unknown as LlmChat; const stream = client.sendMessageStream( request, @@ -8943,6 +8946,20 @@ hello expect(reminderParts(request)).toEqual([CONCISE_REMINDER]); }); + it.each([ + SendMessageType.Retry, + SendMessageType.Notification, + SendMessageType.Teammate, + ])('stays out of %s turns', async (type) => { + vi.mocked(mockConfig.getOutputStyle).mockReturnValue( + getBuiltInOutputStyle('Concise'), + ); + + const request = await runTurn([{ text: 'Hi' }], { type }); + + expect(reminderParts(request)).toEqual([]); + }); + it('reminds on cron-fired turns', async () => { vi.mocked(mockConfig.getOutputStyle).mockReturnValue( getBuiltInOutputStyle('Concise'), From 4529b44fd7675b94fb0a260fe8eb2385299945ad Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sun, 30 Aug 2026 13:37:09 +0000 Subject: [PATCH 10/11] fix(core): treat an empty turnReminder as the generic reminder (#10282) --- packages/core/src/core/output-styles.test.ts | 8 ++++++++ packages/core/src/core/output-styles.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/output-styles.test.ts b/packages/core/src/core/output-styles.test.ts index ac81acd2864..664ef7ef2f1 100644 --- a/packages/core/src/core/output-styles.test.ts +++ b/packages/core/src/core/output-styles.test.ts @@ -82,6 +82,14 @@ describe('built-in output styles', () => { ); }); + it('falls back to the generic reminder for a style with an empty one', () => { + // Style files arrive in the follow-up PR; an empty `turnReminder:` key + // must not render a reminder with no guidance in it. + expect(getOutputStyleTurnReminder({ ...LAYERED, turnReminder: '' })).toBe( + `Layered output style is active. ${DEFAULT_OUTPUT_STYLE_TURN_REMINDER}`, + ); + }); + it('has no duplicate names', () => { const names = BUILT_IN_OUTPUT_STYLES.map((style) => style.name.toLowerCase(), diff --git a/packages/core/src/core/output-styles.ts b/packages/core/src/core/output-styles.ts index bd049d30380..262cebf87d2 100644 --- a/packages/core/src/core/output-styles.ts +++ b/packages/core/src/core/output-styles.ts @@ -186,7 +186,7 @@ export function getOutputStyleTurnReminder( style: OutputStyleDefinition, ): string { return `${style.name} output style is active. ${ - style.turnReminder ?? DEFAULT_OUTPUT_STYLE_TURN_REMINDER + style.turnReminder || DEFAULT_OUTPUT_STYLE_TURN_REMINDER }`; } From 1fffb5a5dc594fa6dfce638710b0807339d12f02 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sun, 30 Aug 2026 14:23:57 +0000 Subject: [PATCH 11/11] test(ci): budget ecs-pool load spikes in three flaky cli suites (#10282) Co-authored-by: Qwen-Coder --- packages/cli/src/commands/update.test.ts | 8 ++++++++ .../src/serve/server-default-bridge-wiring.test.ts | 12 ++++++++++-- .../src/serve/workspace-registration-store.test.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts index 23528c5bd85..bbd1391f145 100644 --- a/packages/cli/src/commands/update.test.ts +++ b/packages/cli/src/commands/update.test.ts @@ -70,6 +70,14 @@ vi.mock('../i18n/index.js', () => ({ const { updateCommand } = await import('./update.js'); +// The ecs-qwen pool runs several jobs at once; under that contention these +// tests pass alone in milliseconds but blow the 15s ceiling without any +// real hang. Give that pool the raised budget its other suites already use. +const timeoutMs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? 60_000 + : 15_000; +vi.setConfig({ testTimeout: timeoutMs, hookTimeout: timeoutMs }); + const updateArgs: ArgumentsCamelCase = { _: [], $0: 'qwen', diff --git a/packages/cli/src/serve/server-default-bridge-wiring.test.ts b/packages/cli/src/serve/server-default-bridge-wiring.test.ts index 7137475f0c7..274fb531054 100644 --- a/packages/cli/src/serve/server-default-bridge-wiring.test.ts +++ b/packages/cli/src/serve/server-default-bridge-wiring.test.ts @@ -46,6 +46,14 @@ function makeBridge( } as unknown as AcpSessionBridge; } +// The ecs-qwen pool runs several jobs at once; under that contention these +// tests pass alone in milliseconds but blow the 15s ceiling without any +// real hang. Give that pool the raised budget its other suites already use. +const timeoutMs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? 60_000 + : 15_000; +vi.setConfig({ testTimeout: timeoutMs, hookTimeout: timeoutMs }); + describe('createServeApp default bridge wiring', () => { afterEach(() => { vi.doUnmock('./acp-session-bridge.js'); @@ -125,7 +133,7 @@ describe('createServeApp default bridge wiring', () => { ).toEqual({ kind: 'not_found', }); - }, 15_000); + }); it('keeps the same-host write route disabled for an injected filesystem factory', async () => { let bridgeOptions: BridgeOptions | undefined; @@ -182,7 +190,7 @@ describe('createServeApp default bridge wiring', () => { }), ).rejects.toBe(boundaryError); expect(writeSameHostToolText).not.toHaveBeenCalled(); - }, 15_000); + }); it('wires total admission into the internally-created bridge', async () => { let freshSessionAdmission: BridgeFreshSessionAdmission | undefined; diff --git a/packages/cli/src/serve/workspace-registration-store.test.ts b/packages/cli/src/serve/workspace-registration-store.test.ts index 7dab866bf62..0ba9408b792 100644 --- a/packages/cli/src/serve/workspace-registration-store.test.ts +++ b/packages/cli/src/serve/workspace-registration-store.test.ts @@ -18,6 +18,14 @@ import { workspaceRegistrationScopeHash, } from './workspace-registration-store.js'; +// The ecs-qwen pool runs several jobs at once; under that contention these +// tests pass alone in milliseconds but blow the 15s ceiling without any +// real hang. Give that pool the raised budget its other suites already use. +const timeoutMs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? 60_000 + : 15_000; +vi.setConfig({ testTimeout: timeoutMs, hookTimeout: timeoutMs }); + const cleanup: string[] = []; async function tempHome(): Promise {