Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/cli/src/ui/commands/contextCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Config } from '@qwen-code/qwen-code-core';
import {
getBuiltInOutputStyle,
getCoreSystemPrompt,
resolveInteractionMode,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
import {
collectContextData,
Expand Down Expand Up @@ -50,6 +55,8 @@ function makeMockConfig(contextWindowSize = 32_000): Config {
}),
getVisibleTools: vi.fn().mockReturnValue(new Set()),
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -83,6 +90,8 @@ describe('collectContextData (contextCommand)', () => {
}),
getVisibleTools: vi.fn().mockReturnValue(new Set()),
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -207,6 +216,8 @@ describe('collectContextData (contextCommand)', () => {
}),
getVisibleTools: vi.fn().mockReturnValue(new Set()),
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -254,6 +265,8 @@ describe('collectContextData (contextCommand)', () => {
}),
getVisibleTools: vi.fn().mockReturnValue(new Set(['web_fetch'])),
getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSkillManager: vi.fn().mockReturnValue({
listSkills: vi.fn().mockResolvedValue([]),
Expand All @@ -280,6 +293,7 @@ describe('collectContextData (contextCommand)', () => {
const config = {
...makeMockConfig(),
getUserMemory: vi.fn().mockReturnValue(''),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi
.fn()
.mockReturnValue('# auto memory\nMEMORY_INDEX_MARKER'),
Expand All @@ -306,6 +320,7 @@ describe('collectContextData (contextCommand)', () => {
const config = {
...makeMockConfig(),
getUserMemory: vi.fn().mockReturnValue(memory),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getWorkingDir: vi.fn().mockReturnValue(workingDir),
} as unknown as Config;
Expand All @@ -331,6 +346,7 @@ describe('collectContextData (contextCommand)', () => {
const config = {
...makeMockConfig(),
getUserMemory: vi.fn().mockReturnValue(memory),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getWorkingDir: vi.fn().mockReturnValue(workingDir),
} as unknown as Config;
Expand Down Expand Up @@ -446,6 +462,47 @@ describe('/context shows three-tier thresholds', () => {
expect(text).not.toMatch(/Compaction thresholds/);
});

it('bills the active output style into the system-prompt estimate', async () => {
const concise = getBuiltInOutputStyle('Concise')!;
const plainConfig = makeMockConfig(200_000);
const styledConfig = {
...makeMockConfig(200_000),
getOutputStyle: vi.fn().mockReturnValue(concise),
} as unknown as Config;

// No API token count, so breakdown.systemPrompt is the raw estimate
// rather than a scaled share — the style section shows up undiluted.
const plain = await collectContextData(plainConfig, false);
const styled = await collectContextData(styledConfig, false);

const tokenDelta =
styled.breakdown.systemPrompt - plain.breakdown.systemPrompt;
expect(tokenDelta).toBeGreaterThan(0);

// ...and the delta has to be the style layer itself, not incidental
// drift: estimateTokens bills ASCII at ~4 chars/token.
const mode = resolveInteractionMode(styledConfig);
const charDelta =
getCoreSystemPrompt(undefined, 'test-model', undefined, mode, concise)
.length -
getCoreSystemPrompt(undefined, 'test-model', undefined, mode, undefined)
.length;
expect(tokenDelta).toBeGreaterThan(charDelta / 4 - 5);
expect(tokenDelta).toBeLessThan(charDelta / 4 + 5);
});

it('estimates the custom system prompt used by the live client', async () => {
const config = {
...makeMockConfig(200_000),
getSystemPrompt: vi.fn().mockReturnValue('CUSTOM'),
getOutputStyle: vi.fn().mockReturnValue(getBuiltInOutputStyle('Concise')),
} as unknown as Config;

const data = await collectContextData(config, false);

expect(data.breakdown.systemPrompt).toBe(2);
});

it('propagates custom autoCompactThreshold through to /context thresholds', async () => {
// config.getAutoCompactThreshold() returns 0.5 → computeThresholds(32000, 0.5)
// = { warn: 0, auto: 16,000, hard: 19,000, effectiveWindow: 12,000 }
Expand Down
10 changes: 2 additions & 8 deletions packages/cli/src/ui/commands/contextCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import {
import {
DiscoveredMCPTool,
uiTelemetryService,
getCoreSystemPrompt,
resolveInteractionMode,
getMainSessionBaseSystemPrompt,
DEFAULT_TOKEN_LIMIT,
ToolNames,
buildSkillLlmContent,
Expand Down Expand Up @@ -139,12 +138,7 @@ export async function collectContextData(
// refines the messages-vs-cache split, not the headline total or tier.
const apiCachedTokens = uiTelemetryService.getLastCachedContentTokenCount();

const systemPromptText = getCoreSystemPrompt(
undefined,
modelName,
undefined,
resolveInteractionMode(config),
);
const systemPromptText = getMainSessionBaseSystemPrompt(config);
const systemPromptTokens = estimateTokens(systemPromptText);

const toolRegistry = config.getToolRegistry();
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/agents/arena/ArenaManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ArenaEventType } from './arena-events.js';
import { ArenaSessionStatus, ARENA_MAX_AGENTS } from './types.js';
import { AgentStatus } from '../runtime/agent-types.js';
import { ApprovalMode } from '../../config/config.js';
import { getBuiltInOutputStyle } from '../../core/output-styles.js';

const hoistedMockSetupWorktrees = vi.hoisted(() => vi.fn());
const hoistedMockCleanupSession = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -72,6 +73,7 @@ const createMockConfig = (
getModel: () => 'test-model',
getSessionId: () => 'test-session',
getUserMemory: () => '',
getOutputStyle: (): ReturnType<typeof getBuiltInOutputStyle> => undefined,
getAutoMemoryPrompt: () => '',
getToolRegistry: () => ({
getFunctionDeclarations: () => [],
Expand Down Expand Up @@ -405,14 +407,18 @@ describe('ArenaManager', () => {
}
});

it('builds the in-process worker system prompt in headless interaction mode', async () => {
it('builds the in-process worker prompt with headless mode and the active style', async () => {
// Arena workers run non-interactively, so ArenaManager passes 'headless'
// as the interaction mode (4th arg) to getCoreSystemPrompt. A regression
// that drops that argument would fall back to the interactive prompt,
// telling arena workers to ask the user questions no one can answer.
// Assert on the produced prompt: the headless variant carries a
// single-turn marker that is absent from every other interaction mode.
mockBackend.type = 'in-process';
mockConfig = {
...createMockConfig(tempDir, { worktreeBaseDir: tempDir }),
getOutputStyle: () => getBuiltInOutputStyle('Concise'),
};
const manager = new ArenaManager(mockConfig as never);

await manager.start(createValidStartOptions());
Expand All @@ -429,6 +435,7 @@ describe('ArenaManager', () => {
expect(systemPrompt).toContain(
'This is a non-interactive, single-turn run',
);
expect(systemPrompt).toContain('# Output Style: Concise');
}
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/agents/arena/ArenaManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,7 @@ export class ArenaManager {
model.modelId,
undefined,
'headless',
this.config.getOutputStyle(),
),
contextFiles: this.config.getUserMemory(),
}),
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { TeamContext } from '../agents/team/types.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { GeminiClient } from '../core/client.js';
import { resolveInteractionMode } from '../core/prompts.js';
import type { OutputStyleDefinition } from '../core/output-styles.js';
import {
AuthType,
createContentGenerator,
Expand Down Expand Up @@ -975,6 +976,7 @@ export interface ConfigParameters {
question?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
outputStyle?: OutputStyleDefinition;
coreTools?: string[];
allowedTools?: string[];
excludeTools?: string[];
Expand Down Expand Up @@ -1858,6 +1860,7 @@ export class Config {
private readonly systemPrompt: string | undefined;
private readonly appendSystemPrompt: string | undefined;
private liveAppendSystemPrompt: string | undefined;
private outputStyle: OutputStyleDefinition | undefined;
private readonly coreTools: string[] | undefined;
private readonly allowedTools: string[] | undefined;
private readonly excludeTools: string[] | undefined;
Expand Down Expand Up @@ -2197,6 +2200,7 @@ export class Config {
this.question = params.question;
this.systemPrompt = params.systemPrompt;
this.appendSystemPrompt = params.appendSystemPrompt;
this.outputStyle = params.outputStyle;
this.coreTools = params.coreTools;
this.allowedTools = params.allowedTools;
this.excludeTools = params.excludeTools;
Expand Down Expand Up @@ -5483,6 +5487,19 @@ export class Config {
this.liveAppendSystemPrompt = prompt;
}

getOutputStyle(): OutputStyleDefinition | undefined {
return this.outputStyle;
}

/**
* Swaps the active output style. Callers that change it mid-session must
* follow up with `GeminiClient.refreshSystemInstruction()`, since the style
* lives in the stable layer of an already-bound system instruction.
*/
setOutputStyle(style: OutputStyleDefinition | undefined): void {
this.outputStyle = style;
}

/** @deprecated Use getPermissionsAllow() instead. */
getCoreTools(): string[] | undefined {
if (this.getBareMode()) {
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
getCustomSystemPrompt,
getPlanModeSystemReminder,
} from './prompts.js';
import { getBuiltInOutputStyle } from './output-styles.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { promptIdContext } from '../utils/promptIdContext.js';
Expand Down Expand Up @@ -602,6 +603,7 @@ describe('Gemini Client (client.ts)', () => {
getAutoMemoryPrompt: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getAppendSystemPrompt: vi.fn().mockReturnValue(undefined),
getOutputStyle: vi.fn().mockReturnValue(undefined),
getStaticSystemPrefix: vi.fn().mockReturnValue(undefined),
setStaticSystemPrefix: vi.fn(),
getFullContext: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -13260,6 +13262,7 @@ Other open files:
'test-model',
undefined,
'headless',
undefined,
);
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -13271,6 +13274,30 @@ Other open files:
);
});

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(getCoreSystemPrompt).mockClear();
vi.spyOn(client['config'], 'getOutputStyle').mockReturnValue(concise);

await client.generateContent(
contents,
{},
abortSignal,
DEFAULT_QWEN_FLASH_MODEL,
);

expect(getCoreSystemPrompt).toHaveBeenCalledWith(
undefined,
'test-model',
undefined,
'headless',
concise,
);
});

it.each([
['interactive', true, false],
['acp', false, true],
Expand Down Expand Up @@ -13299,6 +13326,7 @@ Other open files:
'test-model',
undefined,
mode,
undefined,
);
},
);
Expand Down
35 changes: 26 additions & 9 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,31 @@ const SKILL_WRITE_TOOL_NAMES: ReadonlySet<string> = 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;
Expand Down Expand Up @@ -1161,15 +1186,7 @@ export class GeminiClient {
}

private getMainSessionSystemInstruction(): string {
const overrideSystemPrompt = this.config.getSystemPrompt();
const base = overrideSystemPrompt
? getCustomSystemPrompt(overrideSystemPrompt)
: getCoreSystemPrompt(
undefined,
this.config.getModel(),
undefined,
resolveInteractionMode(this.config),
);
const base = getMainSessionBaseSystemPrompt(this.config);
const stableLayers = {
base,
contextFiles: this.config.getUserMemory(),
Expand Down
Loading
Loading