feat: add system prompt customization options in SDK and CLI

This commit is contained in:
DragonnZhang 2026-03-16 02:47:06 +08:00
parent 110fcd7b7b
commit ee33a3c35e
17 changed files with 529 additions and 14 deletions

View file

@ -241,6 +241,30 @@ describe('parseArguments', () => {
expect(argv.prompt).toBeUndefined(); expect(argv.prompt).toBeUndefined();
}); });
it('should parse --system-prompt', async () => {
process.argv = [
'node',
'script.js',
'--system-prompt',
'You are a test system prompt.',
];
const argv = await parseArguments();
expect(argv.systemPrompt).toBe('You are a test system prompt.');
expect(argv.appendSystemPrompt).toBeUndefined();
});
it('should parse --append-system-prompt', async () => {
process.argv = [
'node',
'script.js',
'--append-system-prompt',
'Be extra concise.',
];
const argv = await parseArguments();
expect(argv.appendSystemPrompt).toBe('Be extra concise.');
expect(argv.systemPrompt).toBeUndefined();
});
it('should allow -r flag as alias for --resume', async () => { it('should allow -r flag as alias for --resume', async () => {
process.argv = [ process.argv = [
'node', 'node',
@ -432,6 +456,21 @@ describe('parseArguments', () => {
mockExit.mockRestore(); mockExit.mockRestore();
}); });
it('should allow --system-prompt and --append-system-prompt together', async () => {
process.argv = [
'node',
'script.js',
'--system-prompt',
'Override prompt',
'--append-system-prompt',
'Append prompt',
];
const argv = await parseArguments();
expect(argv.systemPrompt).toBe('Override prompt');
expect(argv.appendSystemPrompt).toBe('Append prompt');
});
it('should throw an error when include-partial-messages is used without stream-json output', async () => { it('should throw an error when include-partial-messages is used without stream-json output', async () => {
process.argv = ['node', 'script.js', '--include-partial-messages']; process.argv = ['node', 'script.js', '--include-partial-messages'];

View file

@ -111,6 +111,8 @@ export interface CliArgs {
debug: boolean | undefined; debug: boolean | undefined;
prompt: string | undefined; prompt: string | undefined;
promptInteractive: string | undefined; promptInteractive: string | undefined;
systemPrompt: string | undefined;
appendSystemPrompt: string | undefined;
yolo: boolean | undefined; yolo: boolean | undefined;
approvalMode: string | undefined; approvalMode: string | undefined;
telemetry: boolean | undefined; telemetry: boolean | undefined;
@ -290,6 +292,16 @@ export async function parseArguments(): Promise<CliArgs> {
description: description:
'Execute the provided prompt and continue in interactive mode', 'Execute the provided prompt and continue in interactive mode',
}) })
.option('system-prompt', {
type: 'string',
description:
'Override the main session system prompt for this run. Can be combined with --append-system-prompt.',
})
.option('append-system-prompt', {
type: 'string',
description:
'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.',
})
.option('sandbox', { .option('sandbox', {
alias: 's', alias: 's',
type: 'boolean', type: 'boolean',
@ -962,6 +974,8 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat || 'tree', importFormat: settings.context?.importFormat || 'tree',
debugMode, debugMode,
question, question,
systemPrompt: argv.systemPrompt,
appendSystemPrompt: argv.appendSystemPrompt,
coreTools: argv.coreTools || settings.tools?.core || undefined, coreTools: argv.coreTools || settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools, excludeTools,

View file

@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => {
debug: undefined, debug: undefined,
prompt: undefined, prompt: undefined,
promptInteractive: undefined, promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
query: undefined, query: undefined,
yolo: undefined, yolo: undefined,
approvalMode: undefined, approvalMode: undefined,

View file

@ -248,6 +248,26 @@ describe('Server Config (config.ts)', () => {
); );
}); });
it('should store a system prompt override', () => {
const config = new Config({
...baseParams,
systemPrompt: 'You are a custom system prompt.',
});
expect(config.getSystemPrompt()).toBe('You are a custom system prompt.');
expect(config.getAppendSystemPrompt()).toBeUndefined();
});
it('should store an appended system prompt', () => {
const config = new Config({
...baseParams,
appendSystemPrompt: 'Be extra concise.',
});
expect(config.getAppendSystemPrompt()).toBe('Be extra concise.');
expect(config.getSystemPrompt()).toBeUndefined();
});
describe('initialize', () => { describe('initialize', () => {
it('should throw an error if checkpointing is enabled and GitService fails', async () => { it('should throw an error if checkpointing is enabled and GitService fails', async () => {
const gitError = new Error('Git is not installed'); const gitError = new Error('Git is not installed');

View file

@ -298,6 +298,8 @@ export interface ConfigParameters {
debugMode: boolean; debugMode: boolean;
includePartialMessages?: boolean; includePartialMessages?: boolean;
question?: string; question?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
coreTools?: string[]; coreTools?: string[];
allowedTools?: string[]; allowedTools?: string[];
excludeTools?: string[]; excludeTools?: string[];
@ -451,6 +453,8 @@ export class Config {
private readonly outputFormat: OutputFormat; private readonly outputFormat: OutputFormat;
private readonly includePartialMessages: boolean; private readonly includePartialMessages: boolean;
private readonly question: string | undefined; private readonly question: string | undefined;
private readonly systemPrompt: string | undefined;
private readonly appendSystemPrompt: string | undefined;
private readonly coreTools: string[] | undefined; private readonly coreTools: string[] | undefined;
private readonly allowedTools: string[] | undefined; private readonly allowedTools: string[] | undefined;
private readonly excludeTools: string[] | undefined; private readonly excludeTools: string[] | undefined;
@ -561,6 +565,8 @@ export class Config {
this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT; this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT;
this.includePartialMessages = params.includePartialMessages ?? false; this.includePartialMessages = params.includePartialMessages ?? false;
this.question = params.question; this.question = params.question;
this.systemPrompt = params.systemPrompt;
this.appendSystemPrompt = params.appendSystemPrompt;
this.coreTools = params.coreTools; this.coreTools = params.coreTools;
this.allowedTools = params.allowedTools; this.allowedTools = params.allowedTools;
this.excludeTools = params.excludeTools; this.excludeTools = params.excludeTools;
@ -1208,6 +1214,14 @@ export class Config {
return this.question; return this.question;
} }
getSystemPrompt(): string | undefined {
return this.systemPrompt;
}
getAppendSystemPrompt(): string | undefined {
return this.appendSystemPrompt;
}
getCoreTools(): string[] | undefined { getCoreTools(): string[] | undefined {
return this.coreTools; return this.coreTools;
} }

View file

@ -31,7 +31,7 @@ import {
Turn, Turn,
type ChatCompressionInfo, type ChatCompressionInfo,
} from './turn.js'; } from './turn.js';
import { getCoreSystemPrompt } from './prompts.js'; import { getCoreSystemPrompt, getCustomSystemPrompt } from './prompts.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { setSimulate429 } from '../utils/testUtils.js'; import { setSimulate429 } from '../utils/testUtils.js';
@ -314,6 +314,8 @@ describe('Gemini Client (client.ts)', () => {
getVertexAI: vi.fn().mockReturnValue(false), getVertexAI: vi.fn().mockReturnValue(false),
getUserAgent: vi.fn().mockReturnValue('test-agent'), getUserAgent: vi.fn().mockReturnValue('test-agent'),
getUserMemory: vi.fn().mockReturnValue(''), getUserMemory: vi.fn().mockReturnValue(''),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getAppendSystemPrompt: vi.fn().mockReturnValue(undefined),
getFullContext: vi.fn().mockReturnValue(false), getFullContext: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'), getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProxy: vi.fn().mockReturnValue(undefined), getProxy: vi.fn().mockReturnValue(undefined),
@ -2362,6 +2364,104 @@ Other open files:
); );
}); });
it('should use config system prompt override when provided', async () => {
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const abortSignal = new AbortController().signal;
vi.spyOn(client['config'], 'getSystemPrompt').mockReturnValue(
'Override prompt',
);
vi.spyOn(client['config'], 'getUserMemory').mockReturnValue(
'Saved memory',
);
vi.mocked(getCustomSystemPrompt).mockReturnValueOnce(
'Override prompt with memory',
);
await client.generateContent(
contents,
{},
abortSignal,
DEFAULT_QWEN_FLASH_MODEL,
);
expect(getCustomSystemPrompt).toHaveBeenCalledWith(
'Override prompt',
'Saved memory',
undefined,
);
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
systemInstruction: 'Override prompt with memory',
}),
}),
'test-session-id',
);
});
it('should append config appendSystemPrompt to the core system prompt', async () => {
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.',
);
await client.generateContent(
contents,
{},
abortSignal,
DEFAULT_QWEN_FLASH_MODEL,
);
expect(getCoreSystemPrompt).toHaveBeenCalledWith(
'',
'test-model',
'Be extra concise.',
);
});
it('should append config appendSystemPrompt after a config system prompt override', async () => {
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const abortSignal = new AbortController().signal;
vi.spyOn(client['config'], 'getSystemPrompt').mockReturnValue(
'Override prompt',
);
vi.spyOn(client['config'], 'getAppendSystemPrompt').mockReturnValue(
'Focus on findings only.',
);
vi.spyOn(client['config'], 'getUserMemory').mockReturnValue(
'Saved memory',
);
vi.mocked(getCustomSystemPrompt).mockReturnValueOnce(
'Override prompt with memory and append',
);
await client.generateContent(
contents,
{},
abortSignal,
DEFAULT_QWEN_FLASH_MODEL,
);
expect(getCustomSystemPrompt).toHaveBeenCalledWith(
'Override prompt',
'Saved memory',
'Focus on findings only.',
);
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
systemInstruction: 'Override prompt with memory and append',
}),
}),
'test-session-id',
);
});
// Note: there is currently no "fallback mode" model routing; the model used // Note: there is currently no "fallback mode" model routing; the model used
// is always the one explicitly requested by the caller. // is always the one explicitly requested by the caller.
}); });

View file

@ -183,6 +183,26 @@ export class GeminiClient {
}); });
} }
private getMainSessionSystemInstruction(): string {
const userMemory = this.config.getUserMemory();
const overrideSystemPrompt = this.config.getSystemPrompt();
const appendSystemPrompt = this.config.getAppendSystemPrompt();
if (overrideSystemPrompt) {
return getCustomSystemPrompt(
overrideSystemPrompt,
userMemory,
appendSystemPrompt,
);
}
return getCoreSystemPrompt(
userMemory,
this.config.getModel(),
appendSystemPrompt,
);
}
async startChat(extraHistory?: Content[]): Promise<GeminiChat> { async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
this.forceFullIdeContext = true; this.forceFullIdeContext = true;
this.hasFailedCompressionAttempt = false; this.hasFailedCompressionAttempt = false;
@ -194,9 +214,7 @@ export class GeminiClient {
const history = await getInitialChatHistory(this.config, extraHistory); const history = await getInitialChatHistory(this.config, extraHistory);
try { try {
const userMemory = this.config.getUserMemory(); const systemInstruction = this.getMainSessionSystemInstruction();
const model = this.config.getModel();
const systemInstruction = getCoreSystemPrompt(userMemory, model);
return new GeminiChat( return new GeminiChat(
this.config, this.config,
@ -690,7 +708,7 @@ export class GeminiClient {
const userMemory = this.config.getUserMemory(); const userMemory = this.config.getUserMemory();
const finalSystemInstruction = generationConfig.systemInstruction const finalSystemInstruction = generationConfig.systemInstruction
? getCustomSystemPrompt(generationConfig.systemInstruction, userMemory) ? getCustomSystemPrompt(generationConfig.systemInstruction, userMemory)
: getCoreSystemPrompt(userMemory, this.config.getModel()); : this.getMainSessionSystemInstruction();
const requestConfig: GenerateContentConfig = { const requestConfig: GenerateContentConfig = {
abortSignal, abortSignal,

View file

@ -80,6 +80,35 @@ describe('Core System Prompt (prompts.ts)', () => {
expect(prompt).toMatchSnapshot(); // Snapshot the combined prompt expect(prompt).toMatchSnapshot(); // Snapshot the combined prompt
}); });
it('should append extra system prompt instructions after user memory when provided', () => {
vi.stubEnv('SANDBOX', undefined);
const memory = 'Remember the project conventions.';
const appendInstruction = 'Always answer in exactly one sentence.';
const prompt = getCoreSystemPrompt(memory, undefined, appendInstruction);
expect(prompt).toContain(`\n\n---\n\n${memory}`);
expect(prompt).toContain(`\n\n---\n\n${appendInstruction}`);
expect(prompt.indexOf(memory)).toBeLessThan(
prompt.indexOf(appendInstruction),
);
});
it('should append extra instructions after a custom system prompt and user memory', () => {
const customInstruction = 'You are a release manager.';
const userMemory = 'The repo uses pnpm.';
const appendInstruction = 'Only report blocking issues.';
const result = getCustomSystemPrompt(
customInstruction,
userMemory,
appendInstruction,
);
expect(result).toBe(
[customInstruction, userMemory, appendInstruction].join('\n\n---\n\n'),
);
});
it('should include sandbox-specific instructions when SANDBOX env var is set', () => { it('should include sandbox-specific instructions when SANDBOX env var is set', () => {
vi.stubEnv('SANDBOX', 'true'); // Generic sandbox value vi.stubEnv('SANDBOX', 'true'); // Generic sandbox value
const prompt = getCoreSystemPrompt(); const prompt = getCoreSystemPrompt();

View file

@ -72,11 +72,13 @@ export function resolvePathFromEnv(envVar?: string): {
* *
* @param customInstruction - Custom system instruction (ContentUnion from @google/genai) * @param customInstruction - Custom system instruction (ContentUnion from @google/genai)
* @param userMemory - User memory to append * @param userMemory - User memory to append
* @returns Processed custom system instruction with user memory appended * @param appendInstruction - Extra instructions to append after user memory
* @returns Processed custom system instruction with user memory and extra append instructions applied
*/ */
export function getCustomSystemPrompt( export function getCustomSystemPrompt(
customInstruction: GenerateContentConfig['systemInstruction'], customInstruction: GenerateContentConfig['systemInstruction'],
userMemory?: string, userMemory?: string,
appendInstruction?: string,
): string { ): string {
// Extract text from custom instruction // Extract text from custom instruction
let instructionText = ''; let instructionText = '';
@ -100,17 +102,20 @@ export function getCustomSystemPrompt(
} }
// Append user memory using the same pattern as getCoreSystemPrompt // Append user memory using the same pattern as getCoreSystemPrompt
const memorySuffix = const memorySuffix = buildSystemPromptSuffix(userMemory);
userMemory && userMemory.trim().length > 0
? `\n\n---\n\n${userMemory.trim()}`
: '';
return `${instructionText}${memorySuffix}`; return `${instructionText}${memorySuffix}${buildSystemPromptSuffix(appendInstruction)}`;
}
function buildSystemPromptSuffix(text?: string): string {
const trimmed = text?.trim();
return trimmed ? `\n\n---\n\n${trimmed}` : '';
} }
export function getCoreSystemPrompt( export function getCoreSystemPrompt(
userMemory?: string, userMemory?: string,
model?: string, model?: string,
appendInstruction?: string,
): string { ): string {
// if QWEN_SYSTEM_MD is set (and not 0|false), override system prompt from file // if QWEN_SYSTEM_MD is set (and not 0|false), override system prompt from file
// default path is .qwen/system.md but can be modified via custom path in QWEN_SYSTEM_MD // default path is .qwen/system.md but can be modified via custom path in QWEN_SYSTEM_MD
@ -338,10 +343,11 @@ Your core function is efficient and safe assistance. Balance extreme conciseness
const memorySuffix = const memorySuffix =
userMemory && userMemory.trim().length > 0 userMemory && userMemory.trim().length > 0
? `\n\n---\n\n${userMemory.trim()}` ? buildSystemPromptSuffix(userMemory)
: ''; : '';
const appendSuffix = buildSystemPromptSuffix(appendInstruction);
return `${basePrompt}${memorySuffix}`; return `${basePrompt}${memorySuffix}${appendSuffix}`;
} }
/** /**

View file

@ -60,6 +60,7 @@ Creates a new query session with the Qwen Code.
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | | `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | | `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | | `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | | `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
@ -247,6 +248,36 @@ const result = query({
}); });
``` ```
### Override the System Prompt
```typescript
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Say hello in one sentence.',
options: {
systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.',
},
});
```
### Append to the Built-in System Prompt
```typescript
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Review the current directory.',
options: {
systemPrompt: {
type: 'preset',
preset: 'qwen_code',
append: 'Be terse and focus on concrete findings.',
},
},
});
```
### With SDK-Embedded MCP Servers ### With SDK-Embedded MCP Servers
The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process. The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.

View file

@ -55,6 +55,8 @@ export type {
PermissionMode, PermissionMode,
CanUseTool, CanUseTool,
PermissionResult, PermissionResult,
QuerySystemPrompt,
QuerySystemPromptPreset,
CLIMcpServerConfig, CLIMcpServerConfig,
McpServerConfig, McpServerConfig,
McpOAuthConfig, McpOAuthConfig,

View file

@ -7,7 +7,11 @@ import { serializeJsonLine } from '../utils/jsonLines.js';
import { ProcessTransport } from '../transport/ProcessTransport.js'; import { ProcessTransport } from '../transport/ProcessTransport.js';
import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js'; import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js';
import { Query } from './Query.js'; import { Query } from './Query.js';
import type { QueryOptions } from '../types/types.js'; import type {
QueryOptions,
QuerySystemPrompt,
TransportOptions,
} from '../types/types.js';
import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; import { QueryOptionsSchema } from '../types/queryOptionsSchema.js';
import { SdkLogger } from '../utils/logger.js'; import { SdkLogger } from '../utils/logger.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -44,6 +48,7 @@ export function query({
// Generate or use provided session ID for SDK-CLI alignment // Generate or use provided session ID for SDK-CLI alignment
const sessionId = options.resume ?? options.sessionId ?? randomUUID(); const sessionId = options.resume ?? options.sessionId ?? randomUUID();
const resolvedSystemPrompt = resolveSystemPromptOption(options.systemPrompt);
const transport = new ProcessTransport({ const transport = new ProcessTransport({
pathToQwenExecutable, pathToQwenExecutable,
@ -52,6 +57,7 @@ export function query({
model: options.model, model: options.model,
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
env: options.env, env: options.env,
...resolvedSystemPrompt,
abortController, abortController,
debug: options.debug, debug: options.debug,
stderr: options.stderr, stderr: options.stderr,
@ -112,6 +118,20 @@ export function query({
return queryInstance; return queryInstance;
} }
function resolveSystemPromptOption(
systemPrompt: QuerySystemPrompt | undefined,
): Pick<TransportOptions, 'systemPrompt' | 'appendSystemPrompt'> {
if (!systemPrompt) {
return {};
}
if (typeof systemPrompt === 'string') {
return { systemPrompt };
}
return systemPrompt.append ? { appendSystemPrompt: systemPrompt.append } : {};
}
function validateOptions(options: QueryOptions): SpawnInfo | undefined { function validateOptions(options: QueryOptions): SpawnInfo | undefined {
const validationResult = QueryOptionsSchema.safeParse(options); const validationResult = QueryOptionsSchema.safeParse(options);
if (!validationResult.success) { if (!validationResult.success) {

View file

@ -232,6 +232,14 @@ export class ProcessTransport implements Transport {
args.push('--model', this.options.model); args.push('--model', this.options.model);
} }
if (this.options.systemPrompt) {
args.push('--system-prompt', this.options.systemPrompt);
}
if (this.options.appendSystemPrompt) {
args.push('--append-system-prompt', this.options.appendSystemPrompt);
}
if (this.options.permissionMode) { if (this.options.permissionMode) {
args.push('--approval-mode', this.options.permissionMode); args.push('--approval-mode', this.options.permissionMode);
} }

View file

@ -123,12 +123,29 @@ export const TimeoutConfigSchema = z.object({
streamClose: z.number().positive().optional(), streamClose: z.number().positive().optional(),
}); });
const QuerySystemPromptPresetSchema = z
.object({
type: z.literal('preset'),
preset: z.literal('qwen_code'),
append: z
.string()
.min(1, 'systemPrompt.append must be a non-empty string')
.optional(),
})
.strict();
export const QueryOptionsSchema = z export const QueryOptionsSchema = z
.object({ .object({
cwd: z.string().optional(), cwd: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
pathToQwenExecutable: z.string().optional(), pathToQwenExecutable: z.string().optional(),
env: z.record(z.string(), z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
systemPrompt: z
.union([
z.string().min(1, 'systemPrompt must be a non-empty string'),
QuerySystemPromptPresetSchema,
])
.optional(),
permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(), permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(),
canUseTool: z canUseTool: z
.custom<CanUseTool>((val) => typeof val === 'function', { .custom<CanUseTool>((val) => typeof val === 'function', {

View file

@ -16,6 +16,8 @@ export type TransportOptions = {
model?: string; model?: string;
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
env?: Record<string, string>; env?: Record<string, string>;
systemPrompt?: string;
appendSystemPrompt?: string;
abortController?: AbortController; abortController?: AbortController;
debug?: boolean; debug?: boolean;
stderr?: (message: string) => void; stderr?: (message: string) => void;
@ -46,6 +48,14 @@ export type TransportOptions = {
sessionId?: string; sessionId?: string;
}; };
export interface QuerySystemPromptPreset {
type: 'preset';
preset: 'qwen_code';
append?: string;
}
export type QuerySystemPrompt = string | QuerySystemPromptPreset;
type ToolInput = Record<string, unknown>; type ToolInput = Record<string, unknown>;
export type CanUseTool = ( export type CanUseTool = (
@ -226,6 +236,16 @@ export interface QueryOptions {
*/ */
env?: Record<string, string>; env?: Record<string, string>;
/**
* System prompt configuration for the Qwen CLI session.
*
* - `string`: fully overrides the main session system prompt
* - `{ type: 'preset', preset: 'qwen_code', append?: string }`:
* uses Qwen Code's built-in prompt as the base and optionally appends extra
* instructions for the main session
*/
systemPrompt?: QuerySystemPrompt;
/** /**
* Permission mode controlling how the SDK handles tool execution approval. * Permission mode controlling how the SDK handles tool execution approval.
* *

View file

@ -196,6 +196,84 @@ describe('ProcessTransport', () => {
); );
}); });
it('should pass systemPrompt through --system-prompt', () => {
mockPrepareSpawnInfo.mockReturnValue({
command: 'qwen',
args: [],
type: 'native',
originalInput: 'qwen',
});
mockSpawn.mockReturnValue(mockChildProcess);
const options: TransportOptions = {
pathToQwenExecutable: 'qwen',
systemPrompt: 'You are a test system prompt.',
};
new ProcessTransport(options);
expect(mockSpawn).toHaveBeenCalledWith(
'qwen',
expect.arrayContaining([
'--system-prompt',
'You are a test system prompt.',
]),
expect.any(Object),
);
});
it('should pass appendSystemPrompt through --append-system-prompt', () => {
mockPrepareSpawnInfo.mockReturnValue({
command: 'qwen',
args: [],
type: 'native',
originalInput: 'qwen',
});
mockSpawn.mockReturnValue(mockChildProcess);
const options: TransportOptions = {
pathToQwenExecutable: 'qwen',
appendSystemPrompt: 'Be extra concise.',
};
new ProcessTransport(options);
expect(mockSpawn).toHaveBeenCalledWith(
'qwen',
expect.arrayContaining(['--append-system-prompt', 'Be extra concise.']),
expect.any(Object),
);
});
it('should pass both systemPrompt and appendSystemPrompt when provided', () => {
mockPrepareSpawnInfo.mockReturnValue({
command: 'qwen',
args: [],
type: 'native',
originalInput: 'qwen',
});
mockSpawn.mockReturnValue(mockChildProcess);
const options: TransportOptions = {
pathToQwenExecutable: 'qwen',
systemPrompt: 'Override prompt',
appendSystemPrompt: 'Append prompt',
};
new ProcessTransport(options);
expect(mockSpawn).toHaveBeenCalledWith(
'qwen',
expect.arrayContaining([
'--system-prompt',
'Override prompt',
'--append-system-prompt',
'Append prompt',
]),
expect.any(Object),
);
});
it('should include --resume argument when provided', () => { it('should include --resume argument when provided', () => {
mockPrepareSpawnInfo.mockReturnValue({ mockPrepareSpawnInfo.mockReturnValue({
command: 'qwen', command: 'qwen',

View file

@ -0,0 +1,97 @@
/**
* Unit tests for query() option mapping
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { QueryOptions } from '../../src/query/createQuery.js';
const mockProcessTransport = vi.fn();
const mockQuery = vi.fn();
const mockPrepareSpawnInfo = vi.fn();
vi.mock('../../src/transport/ProcessTransport.js', () => ({
ProcessTransport: mockProcessTransport,
}));
vi.mock('../../src/query/Query.js', () => ({
Query: mockQuery,
}));
vi.mock('../../src/utils/cliPath.js', () => ({
prepareSpawnInfo: mockPrepareSpawnInfo,
}));
describe('query()', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPrepareSpawnInfo.mockReturnValue(undefined);
mockProcessTransport.mockImplementation(() => ({
write: vi.fn(),
readMessages: vi.fn(),
close: vi.fn(),
waitForExit: vi.fn(),
endInput: vi.fn(),
exitError: null,
}));
mockQuery.mockImplementation(() => ({
initialized: Promise.resolve(),
getSessionId: () => 'test-session-id',
streamInput: vi.fn(),
}));
});
it('maps string systemPrompt to TransportOptions.systemPrompt', async () => {
const { query } = await import('../../src/query/createQuery.js');
query({
prompt: 'hello',
options: {
systemPrompt: 'You are a strict reviewer.',
} satisfies QueryOptions,
});
expect(mockProcessTransport).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are a strict reviewer.',
}),
);
});
it('maps preset systemPrompt append to TransportOptions.appendSystemPrompt', async () => {
const { query } = await import('../../src/query/createQuery.js');
query({
prompt: 'hello',
options: {
systemPrompt: {
type: 'preset',
preset: 'qwen_code',
append: 'Be terse.',
},
} satisfies QueryOptions,
});
const transportOptions = mockProcessTransport.mock.calls[0]?.[0];
expect(transportOptions.appendSystemPrompt).toBe('Be terse.');
expect(transportOptions.systemPrompt).toBeUndefined();
});
it('rejects non-qwen preset names at runtime validation', async () => {
const { query } = await import('../../src/query/createQuery.js');
expect(() =>
query({
prompt: 'hello',
options: {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
append: 'Be terse.',
} as never,
} satisfies QueryOptions,
}),
).toThrow(/systemPrompt/);
});
});