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: ArgumentsCamelCaseinjected',
+ });
+
+ 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');