merge main branch functionalities

This commit is contained in:
koalazf.99
2025-08-02 14:47:37 +08:00
parent b69b2ce376
commit 23f6ae8513
30 changed files with 1378 additions and 420 deletions

3
.gitignore vendored
View File

@@ -41,4 +41,5 @@ packages/cli/src/generated/
packages/vscode-ide-companion/*.vsix
# Qwen Code Configs
.qwen/
.qwen/
logs/

View File

@@ -6,7 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import { loadCliConfig, parseArguments } from './config.js';
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
@@ -561,6 +561,68 @@ describe('mergeMcpServers', () => {
});
});
describe('loadCliConfig systemPromptMappings', () => {
it('should use default systemPromptMappings when not provided in settings', async () => {
const mockSettings: Settings = {
theme: 'dark',
};
const mockExtensions: Extension[] = [];
const mockSessionId = 'test-session';
const mockArgv: CliArgs = {
model: 'test-model',
} as CliArgs;
const config = await loadCliConfig(
mockSettings,
mockExtensions,
mockSessionId,
mockArgv,
);
expect(config.getSystemPromptMappings()).toEqual([
{
baseUrls: [
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/',
],
modelNames: ['qwen3-coder-plus'],
template:
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
},
]);
});
it('should use custom systemPromptMappings when provided in settings', async () => {
const customSystemPromptMappings = [
{
baseUrls: ['https://custom-api.com'],
modelNames: ['custom-model'],
template: 'Custom template',
},
];
const mockSettings: Settings = {
theme: 'dark',
systemPromptMappings: customSystemPromptMappings,
};
const mockExtensions: Extension[] = [];
const mockSessionId = 'test-session';
const mockArgv: CliArgs = {
model: 'test-model',
} as CliArgs;
const config = await loadCliConfig(
mockSettings,
mockExtensions,
mockSessionId,
mockArgv,
);
expect(config.getSystemPromptMappings()).toEqual(
customSystemPromptMappings,
);
});
});
describe('mergeExcludeTools', () => {
it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };

View File

@@ -441,6 +441,8 @@ export async function loadCliConfig(
model: argv.model!,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
maxFolderItems: settings.maxFolderItems ?? 20,
experimentalAcp: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
@@ -465,6 +467,7 @@ export async function loadCliConfig(
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
},
],
contentGenerator: settings.contentGenerator,
});
}

View File

@@ -95,6 +95,12 @@ export interface Settings {
// Setting for setting maximum number of user/model/tool turns in a session.
maxSessionTurns?: number;
// Setting for maximum token limit for conversation history before blocking requests
sessionTokenLimit?: number;
// Setting for maximum number of files and folders to show in folder structure
maxFolderItems?: number;
// A map of tool names to their summarization settings.
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
@@ -109,6 +115,10 @@ export interface Settings {
modelNames: string[];
template: string;
}>;
contentGenerator?: {
timeout?: number;
maxRetries?: number;
};
}
export interface SettingsError {

View File

@@ -69,7 +69,9 @@ export const createMockCommandContext = (
byName: {},
},
},
promptCount: 0,
} as SessionStatsState,
resetSession: vi.fn(),
},
};

View File

@@ -55,6 +55,7 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockContext.session.resetSession).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
@@ -64,6 +65,8 @@ describe('clearCommand', () => {
const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
.invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const resetSessionOrder = (mockContext.session.resetSession as Mock).mock
.invocationCallOrder[0];
const resetTelemetryOrder = (
uiTelemetryService.resetLastPromptTokenCount as Mock
).mock.invocationCallOrder[0];
@@ -73,6 +76,8 @@ describe('clearCommand', () => {
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
expect(resetChatOrder).toBeLessThan(resetTelemetryOrder);
expect(resetTelemetryOrder).toBeLessThan(clearOrder);
expect(resetChatOrder).toBeLessThan(resetSessionOrder);
expect(resetSessionOrder).toBeLessThan(clearOrder);
});
it('should not attempt to reset chat if config service is not available', async () => {
@@ -92,6 +97,7 @@ describe('clearCommand', () => {
'Clearing terminal.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(nullConfigContext.session.resetSession).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);

View File

@@ -24,6 +24,7 @@ export const clearCommand: SlashCommand = {
}
uiTelemetryService.resetLastPromptTokenCount();
context.session.resetSession();
context.ui.clear();
},
};

View File

@@ -15,10 +15,10 @@ import { MessageType } from '../types.js';
export const docsCommand: SlashCommand = {
name: 'docs',
description: 'open full Gemini CLI documentation in your browser',
description: 'open full Qwen Code documentation in your browser',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://goo.gle/gemini-cli-docs';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
context.ui.addItem(

View File

@@ -13,7 +13,7 @@ import { MessageType } from '../types.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'list available Gemini CLI tools',
description: 'list available Qwen Codetools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
@@ -40,7 +40,7 @@ export const toolsCommand: SlashCommand = {
// Filter out MCP tools by checking for the absence of a serverName property
const geminiTools = tools.filter((tool) => !('serverName' in tool));
let message = 'Available Gemini CLI tools:\n\n';
let message = 'Available Qwen Code tools:\n\n';
if (geminiTools.length > 0) {
geminiTools.forEach((tool) => {

View File

@@ -63,6 +63,7 @@ export interface CommandContext {
// Session-specific data
session: {
stats: SessionStatsState;
resetSession: () => void;
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
};

View File

@@ -50,6 +50,7 @@ interface SessionStatsContextValue {
stats: SessionStatsState;
startNewPrompt: () => void;
getPromptCount: () => number;
resetSession: () => void;
}
// --- Context Definition ---
@@ -109,13 +110,23 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
[stats.promptCount],
);
const resetSession = useCallback(() => {
setStats({
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
promptCount: 0,
});
}, []);
const value = useMemo(
() => ({
stats,
startNewPrompt,
getPromptCount,
resetSession,
}),
[stats, startNewPrompt, getPromptCount],
[stats, startNewPrompt, getPromptCount, resetSession],
);
return (

View File

@@ -162,6 +162,7 @@ export const useSlashCommandProcessor = (
session: {
stats: session.stats,
sessionShellAllowlist,
resetSession: session.resetSession,
},
}),
[
@@ -174,6 +175,7 @@ export const useSlashCommandProcessor = (
clearItems,
refreshStatic,
session.stats,
session.resetSession,
onDebugMessage,
pendingCompressionItemRef,
setPendingCompressionItem,

View File

@@ -511,6 +511,23 @@ export const useGeminiStream = (
[addItem, config],
);
const handleSessionTokenLimitExceededEvent = useCallback(
(value: { currentTokens: number; limit: number; message: string }) =>
addItem(
{
type: 'error',
text:
`🚫 Session token limit exceeded: ${value.currentTokens.toLocaleString()} tokens > ${value.limit.toLocaleString()} limit.\n\n` +
`💡 Solutions:\n` +
` • Start a new session: Use /clear command\n` +
` • Increase limit: Add "sessionTokenLimit": (e.g., 128000) to your settings.json\n` +
` • Compress history: Use /compress command to compress history`,
},
Date.now(),
),
[addItem],
);
const handleLoopDetectedEvent = useCallback(() => {
addItem(
{
@@ -560,6 +577,9 @@ export const useGeminiStream = (
case ServerGeminiEventType.MaxSessionTurns:
handleMaxSessionTurnsEvent();
break;
case ServerGeminiEventType.SessionTokenLimitExceeded:
handleSessionTokenLimitExceededEvent(event.value);
break;
case ServerGeminiEventType.Finished:
handleFinishedEvent(
event as ServerGeminiFinishedEvent,
@@ -591,6 +611,7 @@ export const useGeminiStream = (
handleChatCompressionEvent,
handleFinishedEvent,
handleMaxSessionTurnsEvent,
handleSessionTokenLimitExceededEvent,
],
);

View File

@@ -34,7 +34,7 @@ export async function checkForUpdates(): Promise<string | null> {
notifier.update &&
semver.gt(notifier.update.latest, notifier.update.current)
) {
return `Gemini CLI update available! ${notifier.update.current}${notifier.update.latest}\nRun npm install -g ${packageJson.name} to update`;
return `Qwen Code update available! ${notifier.update.current}${notifier.update.latest}\nRun npm install -g ${packageJson.name} to update`;
}
return null;

View File

@@ -24,7 +24,7 @@ const homeDirectoryCheck: WarningCheck = {
]);
if (workspaceRealPath === homeRealPath) {
return 'You are running Gemini CLI in your home directory. It is recommended to run in a project-specific directory.';
return 'You are running Qwen Code in your home directory. It is recommended to run in a project-specific directory.';
}
return null;
} catch (_err: unknown) {

View File

@@ -39,7 +39,7 @@ export interface HttpOptions {
headers?: Record<string, string>;
}
export const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
export const CODE_ASSIST_ENDPOINT = 'https://localhost:0'; // Disable Google Code Assist API Request
export const CODE_ASSIST_API_VERSION = 'v1internal';
export class CodeAssistServer implements ContentGenerator {

View File

@@ -175,6 +175,8 @@ export interface ConfigParameters {
model: string;
extensionContextFilePaths?: string[];
maxSessionTurns?: number;
sessionTokenLimit?: number;
maxFolderItems?: number;
experimentalAcp?: boolean;
listExtensions?: boolean;
extensions?: GeminiCLIExtension[];
@@ -190,6 +192,10 @@ export interface ConfigParameters {
modelNames: string[];
template: string;
}>;
contentGenerator?: {
timeout?: number;
maxRetries?: number;
};
}
export class Config {
@@ -233,8 +239,15 @@ export class Config {
private readonly noBrowser: boolean;
private readonly ideMode: boolean;
private readonly ideClient: IdeClient | undefined;
private readonly systemPromptMappings?: Array<{
baseUrls?: string[];
modelNames?: string[];
template?: string;
}>;
private modelSwitchedDuringSession: boolean = false;
private readonly maxSessionTurns: number;
private readonly sessionTokenLimit: number;
private readonly maxFolderItems: number;
private readonly listExtensions: boolean;
private readonly _extensions: GeminiCLIExtension[];
private readonly _blockedMcpServers: Array<{
@@ -247,7 +260,12 @@ export class Config {
| Record<string, SummarizeToolOutputSettings>
| undefined;
private readonly experimentalAcp: boolean = false;
private readonly enableOpenAILogging: boolean;
private readonly sampling_params?: Record<string, unknown>;
private readonly contentGenerator?: {
timeout?: number;
maxRetries?: number;
};
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.embeddingModel =
@@ -291,6 +309,8 @@ export class Config {
this.model = params.model;
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.sessionTokenLimit = params.sessionTokenLimit ?? 32000;
this.maxFolderItems = params.maxFolderItems ?? 20;
this.experimentalAcp = params.experimentalAcp ?? false;
this.listExtensions = params.listExtensions ?? false;
this._extensions = params.extensions ?? [];
@@ -299,6 +319,10 @@ export class Config {
this.summarizeToolOutput = params.summarizeToolOutput;
this.ideMode = params.ideMode ?? false;
this.ideClient = params.ideClient;
this.systemPromptMappings = params.systemPromptMappings;
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
this.sampling_params = params.sampling_params;
this.contentGenerator = params.contentGenerator;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -378,6 +402,14 @@ export class Config {
return this.maxSessionTurns;
}
getSessionTokenLimit(): number {
return this.sessionTokenLimit;
}
getMaxFolderItems(): number {
return this.maxFolderItems;
}
setQuotaErrorOccurred(value: boolean): void {
this.quotaErrorOccurred = value;
}
@@ -596,6 +628,32 @@ export class Config {
return this.ideClient;
}
getEnableOpenAILogging(): boolean {
return this.enableOpenAILogging;
}
getSamplingParams(): Record<string, unknown> | undefined {
return this.sampling_params;
}
getContentGeneratorTimeout(): number | undefined {
return this.contentGenerator?.timeout;
}
getContentGeneratorMaxRetries(): number | undefined {
return this.contentGenerator?.maxRetries;
}
getSystemPromptMappings():
| Array<{
baseUrls?: string[];
modelNames?: string[];
template?: string;
}>
| undefined {
return this.systemPromptMappings;
}
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);

File diff suppressed because it is too large Load Diff

View File

@@ -195,9 +195,12 @@ describe('Gemini Client (client.ts)', () => {
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
getFileService: vi.fn().mockReturnValue(fileService),
getMaxSessionTurns: vi.fn().mockReturnValue(0),
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
getMaxFolderItems: vi.fn().mockReturnValue(20),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
getNoBrowser: vi.fn().mockReturnValue(false),
getSystemPromptMappings: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getIdeMode: vi.fn().mockReturnValue(false),
getGeminiClient: vi.fn(),

View File

@@ -182,9 +182,10 @@ export class GeminiClient {
const platform = process.platform;
const folderStructure = await getFolderStructure(cwd, {
fileService: this.config.getFileService(),
maxItems: this.config.getMaxFolderItems(),
});
const context = `
This is the Gemini CLI. We are setting up the context for our chat.
This is the Qwen Code. We are setting up the context for our chat.
Today's date is ${today}.
My operating system is: ${platform}
I'm currently working in the directory: ${cwd}
@@ -319,6 +320,49 @@ export class GeminiClient {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
}
// Check session token limit after compression using accurate token counting
const sessionTokenLimit = this.config.getSessionTokenLimit();
if (sessionTokenLimit > 0) {
// Get all the content that would be sent in an API call
const currentHistory = this.getChat().getHistory(true);
const userMemory = this.config.getUserMemory();
const systemPrompt = getCoreSystemPrompt(userMemory);
const environment = await this.getEnvironment();
// Create a mock request content to count total tokens
const mockRequestContent = [
{
role: 'system' as const,
parts: [{ text: systemPrompt }, ...environment],
},
...currentHistory,
];
// Use the improved countTokens method for accurate counting
const { totalTokens: totalRequestTokens } =
await this.getContentGenerator().countTokens({
model: this.config.getModel(),
contents: mockRequestContent,
});
if (
totalRequestTokens !== undefined &&
totalRequestTokens > sessionTokenLimit
) {
yield {
type: GeminiEventType.SessionTokenLimitExceeded,
value: {
currentTokens: totalRequestTokens,
limit: sessionTokenLimit,
message:
`Session token limit exceeded: ${totalRequestTokens} tokens > ${sessionTokenLimit} limit. ` +
'Please start a new session or increase the sessionTokenLimit in your settings.json.',
},
};
return new Turn(this.getChat(), prompt_id);
}
}
if (this.config.getIdeMode()) {
const openFiles = ideContext.getOpenFilesContext();
if (openFiles) {
@@ -419,7 +463,10 @@ export class GeminiClient {
model || this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(userMemory);
const systemPromptMappings = this.config.getSystemPromptMappings();
const systemInstruction = getCoreSystemPrompt(userMemory, {
systemPromptMappings,
});
const requestConfig = {
abortSignal,
...this.generateContentConfig,
@@ -458,7 +505,30 @@ export class GeminiClient {
throw error;
}
try {
return JSON.parse(text);
// Try to extract JSON from various formats
const extractors = [
// Match ```json ... ``` or ``` ... ``` blocks
/```(?:json)?\s*\n?([\s\S]*?)\n?```/,
// Match inline code blocks `{...}`
/`(\{[\s\S]*?\})`/,
// Match raw JSON objects or arrays
/(\{[\s\S]*\}|\[[\s\S]*\])/,
];
for (const regex of extractors) {
const match = text.match(regex);
if (match && match[1]) {
try {
return JSON.parse(match[1].trim());
} catch {
// Continue to next pattern if parsing fails
continue;
}
}
}
// If no patterns matched, try parsing the entire text
return JSON.parse(text.trim());
} catch (parseError) {
await reportError(
parseError,
@@ -512,7 +582,10 @@ export class GeminiClient {
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(userMemory);
const systemPromptMappings = this.config.getSystemPromptMappings();
const systemInstruction = getCoreSystemPrompt(userMemory, {
systemPromptMappings,
});
const requestConfig = {
abortSignal,

View File

@@ -69,6 +69,10 @@ describe('createContentGeneratorConfig', () => {
setModel: vi.fn(),
flashFallbackHandler: vi.fn(),
getProxy: vi.fn(),
getEnableOpenAILogging: vi.fn().mockReturnValue(false),
getSamplingParams: vi.fn().mockReturnValue(undefined),
getContentGeneratorTimeout: vi.fn().mockReturnValue(undefined),
getContentGeneratorMaxRetries: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
beforeEach(() => {

View File

@@ -85,6 +85,10 @@ export function createContentGeneratorConfig(
model: effectiveModel,
authType,
proxy: config?.getProxy(),
enableOpenAILogging: config.getEnableOpenAILogging(),
timeout: config.getContentGeneratorTimeout(),
maxRetries: config.getContentGeneratorMaxRetries(),
samplingParams: config.getSamplingParams(),
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now

View File

@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { setGlobalDispatcher, ProxyAgent } from 'undici';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
} from '../config/models.js';
// 移除未使用的导入
/**
* Checks if the default "pro" model is rate-limited and returns a fallback "flash"
@@ -19,58 +15,10 @@ import {
* and the original model if a switch happened.
*/
export async function getEffectiveModel(
apiKey: string,
_apiKey: string,
currentConfiguredModel: string,
proxy?: string,
_proxy: string | undefined,
): Promise<string> {
if (currentConfiguredModel !== DEFAULT_GEMINI_MODEL) {
// Only check if the user is trying to use the specific pro model we want to fallback from.
return currentConfiguredModel;
}
const modelToTest = DEFAULT_GEMINI_MODEL;
const fallbackModel = DEFAULT_GEMINI_FLASH_MODEL;
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${modelToTest}:generateContent`;
const body = JSON.stringify({
contents: [{ parts: [{ text: 'test' }] }],
generationConfig: {
maxOutputTokens: 1,
temperature: 0,
topK: 1,
thinkingConfig: { thinkingBudget: 128, includeThoughts: false },
},
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000); // 500ms timeout for the request
try {
if (proxy) {
setGlobalDispatcher(new ProxyAgent(proxy));
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.status === 429) {
console.log(
`[INFO] Your configured model (${modelToTest}) was temporarily unavailable. Switched to ${fallbackModel} for this session.`,
);
return fallbackModel;
}
// For any other case (success, other error codes), we stick to the original model.
return currentConfiguredModel;
} catch (_error) {
clearTimeout(timeoutId);
// On timeout or any other fetch error, stick to the original model.
return currentConfiguredModel;
}
// Disable Google API Model Check
return currentConfiguredModel;
}

View File

@@ -1154,7 +1154,9 @@ export class OpenAIContentGenerator implements ContentGenerator {
}
response.responseId = openaiResponse.id;
response.createTime = openaiResponse.created.toString();
response.createTime = openaiResponse.created
? openaiResponse.created.toString()
: new Date().getTime().toString();
response.candidates = [
{
@@ -1290,7 +1292,9 @@ export class OpenAIContentGenerator implements ContentGenerator {
}
response.responseId = chunk.id;
response.createTime = chunk.created.toString();
response.createTime = chunk.created
? chunk.created.toString()
: new Date().getTime().toString();
response.modelVersion = this.model;
response.promptFeedback = { safetyRatings: [] };

View File

@@ -270,3 +270,96 @@ describe('Core System Prompt (prompts.ts)', () => {
});
});
});
describe('URL matching with trailing slash compatibility', () => {
it('should match URLs with and without trailing slash', () => {
const config = {
systemPromptMappings: [
{
baseUrls: ['https://api.example.com'],
modelNames: ['gpt-4'],
template: 'Custom template for example.com',
},
{
baseUrls: ['https://api.openai.com/'],
modelNames: ['gpt-3.5-turbo'],
template: 'Custom template for openai.com',
},
],
};
// Simulate environment variables
const originalEnv = process.env;
// Test case 1: No trailing slash in config, actual URL has trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.example.com/',
OPENAI_MODEL: 'gpt-4',
};
const result1 = getCoreSystemPrompt(undefined, config);
expect(result1).toContain('Custom template for example.com');
// Test case 2: Config has trailing slash, actual URL has no trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.openai.com',
OPENAI_MODEL: 'gpt-3.5-turbo',
};
const result2 = getCoreSystemPrompt(undefined, config);
expect(result2).toContain('Custom template for openai.com');
// Test case 3: No trailing slash in config, actual URL has no trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.example.com',
OPENAI_MODEL: 'gpt-4',
};
const result3 = getCoreSystemPrompt(undefined, config);
expect(result3).toContain('Custom template for example.com');
// Test case 4: Config has trailing slash, actual URL has trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.openai.com/',
OPENAI_MODEL: 'gpt-3.5-turbo',
};
const result4 = getCoreSystemPrompt(undefined, config);
expect(result4).toContain('Custom template for openai.com');
// Restore original environment variables
process.env = originalEnv;
});
it('should not match when URLs are different', () => {
const config = {
systemPromptMappings: [
{
baseUrls: ['https://api.example.com'],
modelNames: ['gpt-4'],
template: 'Custom template for example.com',
},
],
};
const originalEnv = process.env;
// Test case: URLs do not match
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.different.com',
OPENAI_MODEL: 'gpt-4',
};
const result = getCoreSystemPrompt(undefined, config);
// Should return default template, not contain custom template
expect(result).not.toContain('Custom template for example.com');
// Restore original environment variables
process.env = originalEnv;
});
});

View File

@@ -7,7 +7,6 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { LSTool } from '../tools/ls.js';
import { EditTool } from '../tools/edit.js';
import { GlobTool } from '../tools/glob.js';
import { GrepTool } from '../tools/grep.js';
@@ -19,7 +18,35 @@ import process from 'node:process';
import { isGitRepository } from '../utils/gitUtils.js';
import { MemoryTool, GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
export function getCoreSystemPrompt(userMemory?: string): string {
export interface ModelTemplateMapping {
baseUrls?: string[];
modelNames?: string[];
template?: string;
}
export interface SystemPromptConfig {
systemPromptMappings?: ModelTemplateMapping[];
}
/**
* Normalizes a URL by removing trailing slash for consistent comparison
*/
function normalizeUrl(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
/**
* Checks if a URL matches any URL in the array, ignoring trailing slashes
*/
function urlMatches(urlArray: string[], targetUrl: string): boolean {
const normalizedTarget = normalizeUrl(targetUrl);
return urlArray.some((url) => normalizeUrl(url) === normalizedTarget);
}
export function getCoreSystemPrompt(
userMemory?: string,
config?: SystemPromptConfig,
): string {
// if GEMINI_SYSTEM_MD is set (and not 0|false), override system prompt from file
// default path is .gemini/system.md but can be modified via custom path in GEMINI_SYSTEM_MD
let systemMdEnabled = false;
@@ -44,6 +71,52 @@ export function getCoreSystemPrompt(userMemory?: string): string {
}
}
}
// Check for system prompt mappings from global config
if (config?.systemPromptMappings) {
const currentModel = process.env.OPENAI_MODEL || '';
const currentBaseUrl = process.env.OPENAI_BASE_URL || '';
const matchedMapping = config.systemPromptMappings.find((mapping) => {
const { baseUrls, modelNames } = mapping;
// Check if baseUrl matches (when specified)
if (
baseUrls &&
modelNames &&
urlMatches(baseUrls, currentBaseUrl) &&
modelNames.includes(currentModel)
) {
return true;
}
if (baseUrls && urlMatches(baseUrls, currentBaseUrl) && !modelNames) {
return true;
}
if (modelNames && modelNames.includes(currentModel) && !baseUrls) {
return true;
}
return false;
});
if (matchedMapping?.template) {
const isGitRepo = isGitRepository(process.cwd());
// Replace placeholders in template
let template = matchedMapping.template;
template = template.replace(
'{RUNTIME_VARS_IS_GIT_REPO}',
String(isGitRepo),
);
template = template.replace(
'{RUNTIME_VARS_SANDBOX}',
process.env.SANDBOX || '',
);
return template;
}
}
const basePrompt = systemMdEnabled
? fs.readFileSync(systemMdPath, 'utf8')
: `
@@ -127,7 +200,7 @@ ${(function () {
if (isSandboxExec) {
return `
# macOS Seatbelt
You are running under macos seatbelt with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports. If you encounter failures that could be due to macOS Seatbelt (e.g. if a command fails with 'Operation not permitted' or similar error), as you report the error to the user, also explain why you think it could be due to macOS Seatbelt, and how the user may need to adjust their Seatbelt profile.
You are running under macos seatbelt with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports. If you encounter failures that could be due to MacOS Seatbelt (e.g. if a command fails with 'Operation not permitted' or similar error), as you report the error to the user, also explain why you think it could be due to MacOS Seatbelt, and how the user may need to adjust their Seatbelt profile.
`;
} else if (isGenericSandbox) {
return `
@@ -175,26 +248,52 @@ user: is 13 a prime number?
model: true
</example>
<example>
user: list files here.
model: [tool_call: ${LSTool.Name} for path '/path/to/project']
</example>
<example>
user: start the server implemented in server.js
model: [tool_call: ${ShellTool.Name} for 'node server.js &' because it must run in the background]
model:
<tool_call>
<function=run_shell_command>
<parameter=command>
node server.js &
</parameter>
</function>
</tool_call>
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
[tool_call: ${GlobTool.Name} for path 'tests/test_auth.py']
[tool_call: ${ReadFileTool.Name} for absolute_path '/path/to/tests/test_auth.py']
<tool_call>
<function=glob>
<parameter=path>
tests/test_auth.py
</parameter>
</function>
</tool_call>
<tool_call>
<function=read_file>
<parameter=path>
/path/to/tests/test_auth.py
</parameter>
<parameter=offset>
0
</parameter>
<parameter=limit>
10
</parameter>
</function>
</tool_call>
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
[tool_call: ${ReadFileTool.Name} for absolute_path '/path/to/requirements.txt']
<tool_call>
<function=read_file>
<parameter=path>
/path/to/requirements.txt
</parameter>
</function>
</tool_call>
(After analysis)
Looks good, 'requests' is available.
@@ -204,20 +303,30 @@ Here's the plan:
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
Should I proceed?
user: Yes
model:
[tool_call: ${WriteFileTool.Name} or ${EditTool.Name} to apply the refactoring to 'src/auth.py']
<tool_call>
<function=replace>
<parameter=path>
src/auth.py
</parameter>
<parameter=old_content>
(old code content)
</parameter>
<parameter=new_content>
(new code content)
</parameter>
</function>
</tool_call>
Refactoring complete. Running verification...
[tool_call: ${ShellTool.Name} for 'ruff check src/auth.py && pytest']
<tool_call>
<function=run_shell_command
<parameter=command>
ruff check src/auth.py && pytest
</parameter>
</function>
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
${(function () {
if (isGitRepository(process.cwd())) {
return `Would you like me to write a commit message and commit these changes?`;
}
return '';
})()}
Would you like me to write a commit message and commit these changes?
</example>
<example>
@@ -228,32 +337,48 @@ model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read \`someFile.ts\` to understand its functionality.
[tool_call: ${ReadFileTool.Name} for absolute_path '/path/to/someFile.ts' or use ${GlobTool.Name} to find \`someFile.ts\` if its location is unknown]
Okay, I can write those tests. First, I'll read someFile.ts to understand its functionality.
<tool_call>
<function=read_file>
<parameter=path>
/path/to/someFile.ts
</parameter>
</function>
</tool_call>
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
[tool_call: ${ReadManyFilesTool.Name} for paths ['**/*.test.ts', 'src/**/*.spec.ts'] assuming someFile.ts is in the src directory]
<tool_call>
<function>read_many_files for paths ['**/*.test.ts', 'src/**/*.spec.ts'] assuming someFile.ts is in the src directory]
</tool_call>
(After reviewing existing tests and the file content)
[tool_call: ${WriteFileTool.Name} to create /path/to/someFile.test.ts with the test code]
<tool_call>
<function=write_file>
<parameter=path>
/path/to/someFile.test.ts
</parameter>
</function>
</tool_call>
I've written the tests. Now I'll run the project's test command to verify them.
[tool_call: ${ShellTool.Name} for 'npm run test']
</example>
<example>
user: How do I update the user's profile information in this system?
model:
I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
[tool_call: ${GrepTool.Name} for pattern 'UserProfile|updateProfile|editUser']
(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
Okay, \`/path/to/UserProfileService.java\` seems like the most relevant file. I'll read its content to understand how updates are handled.
[tool_call: ${ReadFileTool.Name} for absolute_path '/path/to/UserProfileService.java']
(After reading the file)
It appears the \`updateUserProfile\` method in \`UserProfileService.java\` is responsible for this. It expects a user ID and a \`UserProfileDTO\` object...
<tool_call>
<function=run_shell_command>
<parameter=command>
npm run test
</parameter>
</function>
</tool_call>
(After verification passes)
All checks passed. This is a stable checkpoint.
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
[tool_call: ${GlobTool.Name} for pattern '**/app.config']
<tool_call>
<function=glob>
<parameter=pattern>
./**/app.config
</parameter>
</function>
</tool_call>
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config

View File

@@ -50,6 +50,7 @@ export enum GeminiEventType {
ChatCompressed = 'chat_compressed',
Thought = 'thought',
MaxSessionTurns = 'max_session_turns',
SessionTokenLimitExceeded = 'session_token_limit_exceeded',
Finished = 'finished',
LoopDetected = 'loop_detected',
}
@@ -63,6 +64,12 @@ export interface GeminiErrorEventValue {
error: StructuredError;
}
export interface SessionTokenLimitExceededValue {
currentTokens: number;
limit: number;
message: string;
}
export interface ToolCallRequestInfo {
callId: string;
name: string;
@@ -136,6 +143,11 @@ export type ServerGeminiMaxSessionTurnsEvent = {
type: GeminiEventType.MaxSessionTurns;
};
export type ServerGeminiSessionTokenLimitExceededEvent = {
type: GeminiEventType.SessionTokenLimitExceeded;
value: SessionTokenLimitExceededValue;
};
export type ServerGeminiFinishedEvent = {
type: GeminiEventType.Finished;
value: FinishReason;
@@ -156,6 +168,7 @@ export type ServerGeminiStreamEvent =
| ServerGeminiChatCompressedEvent
| ServerGeminiThoughtEvent
| ServerGeminiMaxSessionTurnsEvent
| ServerGeminiSessionTokenLimitExceededEvent
| ServerGeminiFinishedEvent
| ServerGeminiLoopDetectedEvent;

View File

@@ -108,7 +108,7 @@ export class MCPOAuthProvider {
`http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`;
const registrationRequest: OAuthClientRegistrationRequest = {
client_name: 'Gemini CLI MCP Client',
client_name: 'Gemini CLI (Google ADC)',
redirect_uris: [redirectUri],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],

View File

@@ -45,7 +45,7 @@ describe('getFolderStructure', () => {
const structure = await getFolderStructure(testRootDir);
expect(structure.trim()).toBe(
`
Showing up to 200 items (files + folders).
Showing up to 20 items (files + folders).
${testRootDir}${path.sep}
├───fileA1.ts
@@ -60,7 +60,7 @@ ${testRootDir}${path.sep}
const structure = await getFolderStructure(testRootDir);
expect(structure.trim()).toBe(
`
Showing up to 200 items (files + folders).
Showing up to 20 items (files + folders).
${testRootDir}${path.sep}
`
@@ -81,7 +81,7 @@ ${testRootDir}${path.sep}
const structure = await getFolderStructure(testRootDir);
expect(structure.trim()).toBe(
`
Showing up to 200 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (200 items) was reached.
Showing up to 20 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (20 items) was reached.
${testRootDir}${path.sep}
├───.hiddenfile
@@ -108,7 +108,7 @@ ${testRootDir}${path.sep}
ignoredFolders: new Set(['subfolderA', 'node_modules']),
});
const expected = `
Showing up to 200 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (200 items) was reached.
Showing up to 20 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (20 items) was reached.
${testRootDir}${path.sep}
├───.hiddenfile
@@ -129,7 +129,7 @@ ${testRootDir}${path.sep}
fileIncludePattern: /\.ts$/,
});
const expected = `
Showing up to 200 items (files + folders).
Showing up to 20 items (files + folders).
${testRootDir}${path.sep}
├───fileA1.ts

View File

@@ -12,7 +12,7 @@ import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { FileFilteringOptions } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
const MAX_ITEMS = 200;
const MAX_ITEMS = 20;
const TRUNCATION_INDICATOR = '...';
const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']);
@@ -20,7 +20,7 @@ const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']);
/** Options for customizing folder structure retrieval. */
interface FolderStructureOptions {
/** Maximum number of files and folders combined to display. Defaults to 200. */
/** Maximum number of files and folders combined to display. Defaults to 20. */
maxItems?: number;
/** Set of folder names to ignore completely. Case-sensitive. */
ignoredFolders?: Set<string>;