diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e2b92b2840f..72d0434bba4 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5511,6 +5511,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 c65b8d7dd7c..920c9f8e162 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -110,6 +110,9 @@ import { MessageDisplayDispatcher, getPlanModeSystemReminder, getArenaSystemReminder, + getOutputStyleTurnReminder, + resolveMainSessionOutputStyle, + wrapSystemReminder, getStartupContextLength, isSystemReminderContent, buildSessionRecoveryPlanFromApiHistory, @@ -10982,6 +10985,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/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 7b039e02167..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'); 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 { diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index 3845a8b7a46..4a2ee4cad76 100644 --- a/packages/core/src/core/client-goal.test.ts +++ b/packages/core/src/core/client-goal.test.ts @@ -233,6 +233,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 49be3dde597..f3e5fa8e001 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 { LlmClient, SendMessageType, type SteerInput } from './client.js'; import { MESSAGE_DISPLAY_DEBOUNCE_MS } from './message-display-buffer.js'; import { getRecentGitStatus } from '../utils/gitUtils.js'; @@ -8804,6 +8809,200 @@ 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'; + + 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([]), + // 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, + 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('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.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'), + ); + + 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 () => { 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 e27817332b0..f17eb1a59a7 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -71,7 +71,9 @@ import { getCustomSystemPrompt, getPlanModeSystemReminder, resolveInteractionMode, + resolveMainSessionOutputStyle, } from './prompts.js'; +import { getOutputStyleTurnReminder } from './output-styles.js'; import { CompressionStatus, LlmEventType, @@ -129,6 +131,7 @@ import { getDirectoryContextString, getInitialChatHistory, getStartupContextLength, + wrapSystemReminder, type AgentAvailabilityEntry, } from './environmentContext.js'; import { @@ -371,7 +374,11 @@ export function getMainSessionBaseSystemPrompt( config.getModel(), undefined, resolveInteractionMode(config), - config.getOutputStyle(), + // 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), ); } @@ -3646,6 +3653,16 @@ export class LlmClient { } } + // 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)), + ); + } + const userQueryMemory = messageType === SendMessageType.UserQuery ? await this.consumeManagedAutoMemoryRecall('initial') diff --git a/packages/core/src/core/environmentContext.ts b/packages/core/src/core/environmentContext.ts index 1f12ff7d55e..2cba8013fb9 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/output-styles.test.ts b/packages/core/src/core/output-styles.test.ts index 871ba7ae8a5..664ef7ef2f1 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'; @@ -81,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(), @@ -138,3 +147,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..262cebf87d2 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. * @@ -162,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 }`; } diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 088ec7a55c0..9c899687204 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -4,7 +4,7 @@ * 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, @@ -14,10 +14,16 @@ import { resolvePathFromEnv, getCompressionPrompt, 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, + type OutputStyleDefinition, } from './output-styles.js'; import { InputFormat } from '../output/types.js'; import { isGitRepository } from '../utils/gitUtils.js'; @@ -782,6 +788,125 @@ 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); + vi.stubEnv('QWEN_CODE_TOOL_CALL_STYLE', 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 LlmClient 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); + 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', () => { beforeEach(() => { vi.resetAllMocks(); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 9627b6e8773..1c43eccbf43 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'); @@ -199,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. @@ -437,6 +451,32 @@ Interaction mode reminder: ${interaction.questions} `.trim(); } +/** + * 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), + ); +} + /** * Builds the stable base system prompt (identity, mandates, tool guidance). * @@ -459,24 +499,23 @@ 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; + 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; } diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 6924f6942d1..ab5b6645db4 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');